1mod import;
23pub mod json;
24mod refs;
25mod table;
26
27pub use import::import_command;
28
29use crate::yres;
30use yog_data::Path as At;
31use json::num_value;
32use serde_json::{Map, Value, json};
33use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
34use std::fmt::Write as _;
35use std::path::{Path, PathBuf};
36
37pub const CONTENT_DIR: &str = "content";
39pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
41
42pub type Output = (String, Vec<u8>);
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Kind {
48 Block,
50 Item,
52 Recipe,
54 Book,
56 Def,
59}
60
61impl Kind {
62 pub fn of(path: &Path) -> Option<Kind> {
63 match path.extension()?.to_str()? {
64 "yblock" => Some(Kind::Block),
65 "yitem" => Some(Kind::Item),
66 "yrecipe" => Some(Kind::Recipe),
67 "ybook" => Some(Kind::Book),
68 "ydef" => Some(Kind::Def),
69 _ => None,
70 }
71 }
72
73 fn word(self) -> &'static str {
74 match self {
75 Kind::Block => "block",
76 Kind::Item => "item",
77 Kind::Recipe => "recipe",
78 Kind::Book => "book",
79 Kind::Def => "definition",
80 }
81 }
82}
83
84#[derive(Debug, Default)]
86pub struct Compiled {
87 pub outputs: Vec<Output>,
89 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
92 lang_aliases: Vec<(String, String)>,
94 tags: BTreeMap<String, BTreeSet<String>>,
96 pub source: Option<String>,
98 pub data_files: Vec<(String, Vec<u8>)>,
101 pub warnings: Vec<String>,
102 pub blocks: usize,
103 pub items: usize,
104 pub recipes: usize,
105 pub books: usize,
106}
107
108pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
111pub const DISPLAY_SLOTS: [&str; 8] = [
112 "thirdperson_righthand",
113 "thirdperson_lefthand",
114 "firstperson_righthand",
115 "firstperson_lefthand",
116 "gui",
117 "head",
118 "ground",
119 "fixed",
120];
121pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
122pub const RECIPE_TYPES: [&str; 8] =
123 ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
124
125pub struct Template {
127 pub name: &'static str,
128 pub parent: &'static str,
130 pub slots: &'static [&'static str],
132 pub sprite: bool,
134 pub has_particle: bool,
136}
137
138pub const TEMPLATES: &[Template] = &[
139 Template {
140 name: "cube_all",
141 parent: "minecraft:block/cube_all",
142 slots: &["all"],
143 sprite: false,
144 has_particle: true,
145 },
146 Template {
147 name: "cube_column",
148 parent: "minecraft:block/cube_column",
149 slots: &["end", "side"],
150 sprite: false,
151 has_particle: true,
152 },
153 Template {
154 name: "cube_bottom_top",
155 parent: "minecraft:block/cube_bottom_top",
156 slots: &["top", "bottom", "side"],
157 sprite: false,
158 has_particle: true,
159 },
160 Template {
161 name: "cube",
162 parent: "minecraft:block/cube",
163 slots: &["down", "up", "north", "south", "west", "east"],
164 sprite: false,
165 has_particle: false,
166 },
167 Template {
168 name: "orientable",
169 parent: "minecraft:block/orientable",
170 slots: &["front", "side", "top"],
171 sprite: false,
172 has_particle: true,
173 },
174 Template {
175 name: "cross",
176 parent: "minecraft:block/cross",
177 slots: &["cross"],
178 sprite: false,
179 has_particle: true,
180 },
181 Template {
182 name: "generated",
183 parent: "minecraft:item/generated",
184 slots: &["layer0"],
185 sprite: true,
186 has_particle: true,
187 },
188 Template {
189 name: "handheld",
190 parent: "minecraft:item/handheld",
191 slots: &["layer0"],
192 sprite: true,
193 has_particle: true,
194 },
195];
196
197pub fn template(name: &str) -> Option<&'static Template> {
198 TEMPLATES.iter().find(|t| t.name == name)
199}
200
201pub fn valid_namespace(s: &str) -> bool {
203 !s.is_empty()
204 && s.bytes().all(|b| {
205 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
206 })
207}
208
209pub fn valid_path(s: &str) -> bool {
211 !s.is_empty()
212 && !s.starts_with('/')
213 && !s.ends_with('/')
214 && s.bytes().all(|b| {
215 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
216 })
217}
218
219pub fn valid_id(s: &str) -> bool {
221 match s.split_once(':') {
222 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
223 None => valid_path(s),
224 }
225}
226
227pub fn find(root: &Path) -> Vec<PathBuf> {
231 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
232 let Ok(entries) = std::fs::read_dir(dir) else {
233 return;
234 };
235 for entry in entries.flatten() {
236 let path = entry.path();
237 if path.is_dir() {
238 walk(&path, out);
239 } else if Kind::of(&path).is_some() {
240 out.push(path);
241 }
242 }
243 }
244 let mut out = Vec::new();
245 walk(&root.join(CONTENT_DIR), &mut out);
246 out.sort();
247 out
248}
249
250fn display(root: &Path, p: &Path) -> String {
251 p.strip_prefix(root)
252 .unwrap_or(p)
253 .to_string_lossy()
254 .replace('\\', "/")
255}
256
257#[derive(Debug, Clone, Default)]
260struct BlockProps {
261 hardness: Option<f64>,
262 resistance: Option<f64>,
263 sound: Option<String>,
264 light: u8,
265 tools: Vec<String>,
267 tool_level: Option<(String, usize)>,
270 requires_tool: bool,
271 no_collision: bool,
272 slipperiness: Option<f64>,
273 shape: Option<[f64; 6]>,
275 connect_groups: Vec<String>,
276 connects: bool,
277 facing: bool,
278 inventory: Option<String>,
280 redstone: bool,
282}
283
284#[derive(Debug, Clone, Default)]
285struct ItemProps {
286 stack_size: Option<u8>,
287 durability: u32,
288 fire_resistant: bool,
289 fuel_ticks: u32,
290 food: Option<(u32, f64, bool)>,
292 tool: Option<(String, String, f32, f32)>,
294 armor: Option<(String, String)>,
296}
297
298#[derive(Debug, Clone)]
300struct Entry {
301 kind: Kind,
302 id: String,
303 file: String,
304 auto: bool,
305 requires: Vec<String>,
307 block: BlockProps,
308 item: ItemProps,
309}
310
311struct Ctx<'a> {
313 root: &'a Path,
314 refs: &'a yres::Refs,
315 file: String,
316 ns: String,
317 errors: &'a mut Vec<String>,
318 warnings: &'a mut Vec<String>,
319 textures: &'a mut BTreeMap<String, PathBuf>,
321 inventories: &'a mut Vec<(String, usize, String)>,
324 deferred: &'a mut Vec<(String, usize, String, yog_data::Ref)>,
327}
328
329impl Ctx<'_> {
330 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
331 self.errors.push(format!("{}:{line}: {msg}", self.file));
332 }
333
334 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
335 self.warnings.push(format!("{}:{line}: {msg}", self.file));
336 }
337
338 fn err_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
340 let line = loc(path);
341 self.err(line, format!("{path}: {msg}"));
342 }
343
344 fn warn_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
345 let line = loc(path);
346 self.warn(line, format!("{path}: {msg}"));
347 }
348
349 fn schema_errors(&mut self, errors: yog_data::Errors) {
351 for e in errors.0 {
352 let l = e.at.map_or(1, |a| a.line as usize);
353 if e.path.is_root() {
354 self.err(l, e.message)
355 } else {
356 self.err(l, format!("{}: {}", e.path, e.message))
357 }
358 }
359 }
360
361 fn texture(&mut self, loc: Loc, path: &At, t: &yog_formats::content::Texture) -> Option<String> {
365 let s = match t {
366 yog_formats::content::Texture::Slot(slot) => return Some(format!("#{slot}")),
367 yog_formats::content::Texture::Res(r) => {
368 let name = format!("{}.{}", r.set, r.key);
369 return match self.refs.images.get(&name) {
370 Some(id) => Some(id.clone()),
371 None => {
372 self.err_at(loc, path, format!("`@{name}`: no image `{name}` in resources/*.yres"));
373 None
374 }
375 };
376 }
377 yog_formats::content::Texture::Plain(s) => s.trim().to_string(),
378 };
379 if let Some(stem) = s.strip_suffix(".png") {
380 let rel = stem.trim_start_matches("./").trim_start_matches('/');
381 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
382 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
383 self.err_at(loc, path, format!("`{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"));
384 return None;
385 }
386 let file = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png"));
387 if !file.is_file() {
388 self.err_at(loc, path, format!("resources/{rel}.png doesn't exist"));
389 return None;
390 }
391 let target = format!("assets/{}/textures/{rel}.png", self.ns);
392 if let Some(other) = self.textures.insert(target.clone(), file.clone())
393 && other != file
394 {
395 let shown = display(self.root, &other);
396 self.err_at(loc, path, format!("{target} also comes from {shown}"));
397 }
398 let meta = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png.mcmeta"));
399 if meta.is_file() {
400 self.textures.insert(format!("{target}.mcmeta"), meta);
401 }
402 return Some(format!("{}:{rel}", self.ns));
403 }
404 if !valid_id(&s) {
405 self.err_at(loc, path, format!("`{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"));
406 return None;
407 }
408 let id = if s.contains(':') { s } else { format!("minecraft:{s}") };
409 let (ns, tex) = id.split_once(':').unwrap_or_default();
410 if ns == self.ns {
411 let file = self.root.join("assets").join(ns).join("textures").join(format!("{tex}.png"));
412 let bundled = self.textures.contains_key(&format!("assets/{ns}/textures/{tex}.png")) || self.refs.images.values().any(|v| *v == id);
413 if !file.is_file() && !bundled {
414 self.warn_at(loc, path, format!("texture {id} isn't in assets/{ns}/textures/ or resources/"));
415 }
416 }
417 Some(id)
418 }
419}
420
421type Loc<'a> = &'a dyn Fn(&At) -> usize;
423
424struct Model {
426 json: Value,
427 bounds: Option<[f64; 6]>,
429}
430
431fn nums(ns: &[f64]) -> Value {
432 Value::Array(ns.iter().map(|n| num_value(*n)).collect())
433}
434
435fn model(cx: &mut Ctx, loc: Loc, at: &At, m: &yog_formats::content::Model, item: bool) -> Option<Model> {
437 let mut out = Map::new();
438 let mut tpl: Option<&Template> = None;
439 let mut raw_parent = false;
440 if let Some(name) = &m.parent {
441 if let Some(t) = template(name) {
442 if t.sprite && !item {
443 cx.err_at(loc, &at.key("parent"), format!("{name:?} is an item sprite — block models use cube_all, cube_column, ..."));
444 }
445 out.insert("parent".into(), Value::String(t.parent.into()));
446 tpl = Some(t);
447 } else if name.contains(':') && valid_id(name) {
448 out.insert("parent".into(), Value::String(name.clone()));
449 raw_parent = true;
450 } else {
451 let names: Vec<&str> = TEMPLATES.iter().filter(|t| item || !t.sprite).map(|t| t.name).collect();
452 cx.err_at(loc, &at.key("parent"), format!("{name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")));
453 return None;
454 }
455 }
456
457 let mut slots: Vec<String> = Vec::new();
459 let mut textures = Map::new();
460 for (slot, tex) in &m.textures.0 {
461 let here = at.key("textures").key(slot);
462 if slot.is_empty() || slot.starts_with('#') {
463 cx.err_at(loc, &here, "slot names are plain words (`all`, `side`, `0`)");
464 continue;
465 }
466 if let Some(t) = cx.texture(loc, &here, tex) {
467 textures.insert(slot.clone(), Value::String(t));
468 slots.push(slot.clone());
469 }
470 }
471 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
472 if let Some(t) = tpl {
473 if t.sprite {
474 if !defined("layer0", &textures) {
475 cx.err_at(loc, at, format!("parent {:?} needs textures.layer0", t.name));
476 }
477 for slot in &slots {
478 let n = slot.strip_prefix("layer").and_then(|n| n.parse::<usize>().ok());
479 match n {
480 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
481 Some(_) => cx.err_at(loc, &at.key("textures").key(slot), "without the layer before it"),
482 None if slot == "particle" => {}
483 None => cx.warn_at(loc, &at.key("textures").key(slot), "isn't used by an item sprite (layer0, layer1, ...)"),
484 }
485 }
486 } else {
487 for slot in t.slots {
488 if !defined(slot, &textures) {
489 cx.err_at(loc, at, format!("parent {:?} needs textures.{slot}", t.name));
490 }
491 }
492 }
493 }
494
495 let mut bounds: Option<[f64; 6]> = None;
497 if let Some(els) = &m.elements {
498 if let Some(t) = tpl {
499 cx.err_at(loc, &at.key("elements"), format!("elements replace the shape of parent {:?} — remove one of them", t.name));
500 }
501 let mut elements = Vec::new();
502 for (i, el) in els.iter().enumerate() {
503 if let Some(json) = element(cx, loc, &at.key("elements").index(i), el, &textures, raw_parent) {
504 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
505 for a in 0..3 {
506 b[a] = b[a].min(el.from[a].min(el.to[a]));
507 b[a + 3] = b[a + 3].max(el.from[a].max(el.to[a]));
508 }
509 elements.push(json);
510 }
511 }
512 out.insert("elements".into(), Value::Array(elements));
513 } else if tpl.is_none() && !raw_parent {
514 cx.err_at(loc, at, "no parent and no elements — give a template (parent: \"cube_all\") or elements");
515 }
516
517 if !textures.contains_key("particle")
521 && !tpl.is_some_and(|t| t.has_particle)
522 && (!raw_parent || m.elements.is_some())
523 && let Some(first) = slots.first()
524 {
525 let v = textures[first].clone();
526 textures.insert("particle".into(), v);
527 }
528 if !textures.is_empty() {
529 out.insert("textures".into(), Value::Object(textures));
530 }
531 if let Some(d) = &m.display {
532 let mut display = Map::new();
533 for (place, t) in d.iter() {
534 let mut o = Map::new();
535 for (key, v) in [("rotation", t.rotation), ("translation", t.translation), ("scale", t.scale)] {
536 if let Some(v) = v {
537 o.insert(key.into(), nums(&v));
538 }
539 }
540 display.insert(place.into(), Value::Object(o));
541 }
542 out.insert("display".into(), Value::Object(display));
543 }
544 if let Some(b) = m.ambient_occlusion {
545 out.insert("ambientocclusion".into(), Value::Bool(b));
546 }
547 if let Some(g) = &m.gui_light {
548 out.insert("gui_light".into(), Value::String(yog_formats::content::word(g)));
549 }
550 Some(Model { json: Value::Object(out), bounds })
551}
552
553fn element(cx: &mut Ctx, loc: Loc, at: &At, el: &yog_formats::content::Element, textures: &Map<String, Value>, raw_parent: bool) -> Option<Value> {
555 let mut out = Map::new();
556 out.insert("from".into(), nums(&el.from));
557 out.insert("to".into(), nums(&el.to));
558 if let Some(r) = &el.rotation {
559 if (r.angle / 22.5).fract() != 0.0 {
560 cx.err_at(loc, &at.key("rotation").key("angle"), format!("{} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees", r.angle));
561 }
562 let mut rot = Map::new();
563 rot.insert("origin".into(), nums(&r.origin.unwrap_or([8.0, 8.0, 8.0])));
564 rot.insert("axis".into(), Value::String(yog_formats::content::word(&r.axis)));
565 rot.insert("angle".into(), num_value(r.angle));
566 if r.rescale {
567 rot.insert("rescale".into(), Value::Bool(true));
568 }
569 out.insert("rotation".into(), Value::Object(rot));
570 }
571 if el.shade == Some(false) {
572 out.insert("shade".into(), Value::Bool(false));
573 }
574 let mut faces = Map::new();
575 match &el.faces {
576 None => cx.warn_at(loc, at, "no faces — it's invisible"),
577 Some(f) => {
578 for (dir, face) in f.iter() {
579 let here = at.key("faces").key(dir);
580 let mut fo = Map::new();
581 let slot = face.texture.strip_prefix('#').unwrap_or(&face.texture);
582 if !textures.contains_key(slot) && !raw_parent {
583 cx.err_at(loc, &here.key("texture"), format!("`#{slot}`: no such slot in textures"));
584 }
585 fo.insert("texture".into(), Value::String(format!("#{slot}")));
586 if let Some(uv) = face.uv {
587 fo.insert("uv".into(), nums(&uv));
588 }
589 match face.rotation {
590 None | Some(0) => {}
591 Some(n @ (90 | 180 | 270)) => {
592 fo.insert("rotation".into(), Value::from(n));
593 }
594 Some(_) => cx.err_at(loc, &here.key("rotation"), "must be 0, 90, 180 or 270"),
595 }
596 if let Some(c) = &face.cullface {
597 fo.insert("cullface".into(), Value::String(yog_formats::content::word(c)));
598 }
599 if let Some(t) = face.tint
600 && t >= 0
601 {
602 fo.insert("tintindex".into(), Value::from(t));
603 }
604 faces.insert(dir.into(), Value::Object(fo));
605 }
606 }
607 }
608 out.insert("faces".into(), Value::Object(faces));
609 Some(Value::Object(out))
610}
611
612fn file_id(cx: &mut Ctx, line: usize, id: Option<&str>, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
614 let raw = id.map(str::to_string).unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
615 let (ns, p) = match raw.split_once(':') {
616 Some((ns, p)) => (ns.to_string(), p.to_string()),
617 None => match mod_ns {
618 Some(ns) => (ns.to_string(), raw.clone()),
619 None => {
620 cx.err(line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: id: \"mynamespace:name\"");
621 return None;
622 }
623 },
624 };
625 if !valid_namespace(&ns) || !valid_path(&p) {
626 cx.err(line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
627 return None;
628 }
629 Some((ns, p))
630}
631
632fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
634 let mut v = Map::new();
635 v.insert("model".into(), Value::String(model.into()));
636 if x != 0.0 {
637 v.insert("x".into(), num_value(x));
638 }
639 if y != 0.0 {
640 v.insert("y".into(), num_value(y));
641 }
642 if uvlock {
643 v.insert("uvlock".into(), Value::Bool(true));
644 }
645 if let Some(w) = weight {
646 v.insert("weight".into(), num_value(w));
647 }
648 Value::Object(v)
649}
650
651fn pretty(v: &Value) -> Vec<u8> {
652 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
653 s.push('\n');
654 s.into_bytes()
655}
656
657fn compile_file(
660 cx: &mut Ctx,
661 text: &str,
662 kind: Kind,
663 path: &Path,
664 mod_ns: Option<&str>,
665 out: &mut Compiled,
666 generated: &mut BTreeMap<String, String>,
667) -> Option<Entry> {
668 let doc = match yog_data::text::parse(text) {
669 Ok(d) => d,
670 Err(e) => {
671 cx.err(e.at.line as usize, e.message);
672 return None;
673 }
674 };
675 let loc_fn = |p: &At| doc.locate(text, p).line as usize;
676 let loc: Loc = &loc_fn;
677 let root = At::root();
678 struct Parts {
679 format: Option<u32>,
680 id: Option<String>,
681 name: Option<yog_data::Text>,
682 tooltip: Option<yog_data::Text>,
683 register: Option<yog_formats::content::Register>,
684 model: yog_formats::content::Model,
685 item_model: Option<yog_formats::content::Model>,
686 block: Option<yog_formats::content::BlockProps>,
687 states: Option<yog_formats::content::States>,
688 item: Option<yog_formats::content::ItemProps>,
689 requires: Vec<String>,
690 }
691 let (parts, refs) = match kind {
692 Kind::Item => match yog_data::schema::from_doc::<yog_formats::content::ItemFile>(text, &doc) {
693 Ok(r) => {
694 let f = r.value;
695 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: f.model, item_model: None, block: None, states: None, item: f.item, requires: f.requires }, r.refs)
696 }
697 Err(e) => {
698 cx.schema_errors(e);
699 return None;
700 }
701 },
702 Kind::Block => match yog_data::schema::from_doc::<yog_formats::content::BlockFile>(text, &doc) {
703 Ok(r) => {
704 let f = r.value;
705 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: f.model, item_model: f.item_model, block: f.block, states: f.states, item: None, requires: f.requires }, r.refs)
706 }
707 Err(e) => {
708 cx.schema_errors(e);
709 return None;
710 }
711 },
712 Kind::Recipe | Kind::Book | Kind::Def => unreachable!("only blocks and items get here"),
713 };
714 for f in &refs {
716 cx.deferred.push((cx.file.clone(), loc(&f.path), f.path.to_string(), f.value.clone()));
717 }
718 if let Some(f) = parts.format
719 && f != 1
720 {
721 cx.err_at(loc, &root.key("format"), "format must be 1 (this yog understands format 1)");
722 }
723 let (ns, p) = file_id(cx, loc(&root.key("id")), parts.id.as_deref(), path, mod_ns)?;
724 cx.ns = ns.clone();
725 let id = format!("{ns}:{p}");
726 let auto = parts.register.unwrap_or_default() == yog_formats::content::Register::Auto;
727
728 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
730 for (field, text, target) in [("name", &parts.name, key.clone()), ("tooltip", &parts.tooltip, format!("{key}.desc"))] {
731 let Some(t) = text else { continue };
732 let from = match t {
733 yog_data::Text::Literal(s) => {
734 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(target, s.clone());
735 continue;
736 }
737 yog_data::Text::Lang(k) => k.clone(),
738 yog_data::Text::NameOf(r) => {
739 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
740 format!("{}.{rns}.{}", r.kind, rp.replace('/', "."))
741 }
742 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
743 Some((k, _)) => k.clone(),
744 None => {
745 cx.err_at(loc, &root.key(field), format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key));
746 continue;
747 }
748 },
749 };
750 if from != target {
751 out.lang_aliases.push((from, target));
752 }
753 }
754
755 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
756 let full = format!("assets/{ns}/{rel}");
757 if cx.root.join(&full).is_file() {
758 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
759 }
760 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
761 cx.err(1, format!("{full} is also written by {other}"));
762 }
763 out.outputs.push((full, pretty(value)));
764 };
765
766 let mut entry = Entry { kind, id: id.clone(), file: cx.file.clone(), auto, requires: parts.requires.clone(), block: BlockProps::default(), item: ItemProps::default() };
767
768 if kind == Kind::Item {
769 if let Some(it) = &parts.item {
770 let props = &mut entry.item;
771 props.stack_size = it.stack_size;
772 props.durability = it.durability.unwrap_or(0);
773 props.fire_resistant = it.fire_resistant;
774 props.fuel_ticks = it.fuel_ticks.unwrap_or(0);
775 props.food = it.food.as_ref().map(|f| (f.nutrition, f.saturation, f.always_edible));
776 props.tool = it.tool.as_ref().map(|t| {
777 let (d, s) = t.attack();
778 (t.kind.clone(), t.material.clone(), d, s)
779 });
780 props.armor = it.armor.as_ref().map(|a| (yog_formats::content::word(&a.slot), a.material.clone()));
781 if props.tool.is_some() && props.armor.is_some() {
782 cx.err_at(loc, &root.key("item").key("armor"), "an item is a tool or armor, not both");
783 }
784 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
785 cx.warn_at(loc, &root.key("item").key("stack_size"), "ignored: an item with durability stacks to 1");
786 }
787 }
788 let m = model(cx, loc, &root.key("model"), &parts.model, true)?;
789 emit(cx, format!("models/item/{p}.json"), &m.json);
790 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
791 return Some(entry);
792 }
793
794 let b = parts.block.clone().unwrap_or_default();
796 let bat = root.key("block");
797 let props = &mut entry.block;
798 props.hardness = b.hardness;
799 props.resistance = b.resistance;
800 props.sound = b.sound.as_ref().map(yog_formats::content::word);
801 props.light = b.light.unwrap_or(0);
802 props.tools = b.tool.iter().map(|t| t.0.clone()).collect();
803 props.tool_level = b.tool_level.clone().map(|l| (l, loc(&bat.key("tool_level"))));
804 props.requires_tool = b.requires_tool;
805 props.no_collision = b.collision == Some(false);
806 props.slipperiness = b.slipperiness;
807 props.redstone = b.redstone;
808 props.connect_groups = b.connect_groups.clone();
809 let shape_auto = matches!(b.shape, None | Some(yog_formats::content::BlockShape::Auto));
810 if let Some(yog_formats::content::BlockShape::Box(bx)) = b.shape {
811 props.shape = Some(bx);
812 }
813 if let Some(inv) = &b.inventory {
814 if valid_id(inv) && inv.contains(':') {
815 props.inventory = Some(inv.clone());
816 let at = (cx.file.clone(), loc(&bat.key("inventory")), inv.clone());
817 cx.inventories.push(at);
818 } else {
819 cx.err_at(loc, &bat.key("inventory"), format!("`{inv}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
820 }
821 }
822 if props.tool_level.is_some() && props.tools.is_empty() {
823 cx.warn_at(loc, &bat, "tool_level without tool — any tool of that level mines it");
824 }
825 let drops = match &b.drops {
826 None | Some(yog_formats::content::Drops::Itself) => drops_table(&id, None),
827 Some(yog_formats::content::Drops::Nothing) => Value::Null,
828 Some(yog_formats::content::Drops::Item(d)) => drops_table(d.item.as_deref().unwrap_or(&id), d.count),
829 };
830 let render_layer = b.render_layer.as_ref().map(yog_formats::content::word);
831
832 let mut m = model(cx, loc, &root.key("model"), &parts.model, false)?;
833 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
834 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
836 }
837 if shape_auto {
838 entry.block.shape = m
839 .bounds
840 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
841 .map(|b| [b[0].max(0.0), b[1].max(0.0), b[2].max(0.0), b[3].min(16.0), b[4].min(16.0), b[5].min(16.0)]);
842 }
843 let main = format!("{ns}:block/{p}");
844 emit(cx, format!("models/block/{p}.json"), &m.json);
845
846 let mut rotations: Vec<Value> = Vec::new();
848 let mut arm: Option<(String, bool)> = None;
849 if let Some(s) = &parts.states {
850 let sat = root.key("states");
851 if let Some(rs) = &s.rotations {
852 if rs.is_empty() {
853 cx.err_at(loc, &sat.key("rotations"), "a non-empty list of { x: 0, y: 90, weight: 1, uvlock: false }");
854 }
855 for (i, r) in rs.iter().enumerate() {
856 let here = sat.key("rotations").index(i);
857 let mut angle = |k: &str, v: Option<u32>| match v {
858 None => 0.0,
859 Some(n @ (0 | 90 | 180 | 270)) => n as f64,
860 Some(_) => {
861 cx.err_at(loc, &here.key(k), "must be 0, 90, 180 or 270");
862 0.0
863 }
864 };
865 let (x, y) = (angle("x", r.x), angle("y", r.y));
866 rotations.push(variant(&main, x, y, r.uvlock, r.weight));
867 }
868 }
869 if let Some(c) = &s.connect {
870 if let Some(am) = model(cx, loc, &sat.key("connect").key("arm"), &c.arm, false) {
871 let mut json = am.json;
872 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json) {
873 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
874 }
875 emit(cx, format!("models/block/{p}_arm.json"), &json);
876 arm = Some((format!("{ns}:block/{p}_arm"), c.uvlock));
877 }
878 entry.block.connects = true;
879 if entry.block.connect_groups.is_empty() {
880 entry.block.connect_groups.push(id.clone());
881 }
882 }
883 if s.facing.is_some() {
884 if s.rotations.is_some() || s.connect.is_some() {
885 cx.err_at(loc, &sat.key("facing"), "can't go with rotations or connect");
886 }
887 entry.block.facing = true;
888 }
889 }
890 let base: Value = match rotations.len() {
891 0 => variant(&main, 0.0, 0.0, false, None),
892 1 => rotations.remove(0),
893 _ => Value::Array(rotations),
894 };
895 let blockstate = match &arm {
896 None if entry.block.facing => json!({ "variants": {
898 "facing=north": variant(&main, 0.0, 0.0, false, None),
899 "facing=east": variant(&main, 0.0, 90.0, false, None),
900 "facing=south": variant(&main, 0.0, 180.0, false, None),
901 "facing=west": variant(&main, 0.0, 270.0, false, None),
902 } }),
903 None => json!({ "variants": { "": base } }),
904 Some((arm, uvlock)) => {
905 let mut parts = vec![json!({ "apply": base })];
906 for (dir, x, y) in [("north", 0.0, 0.0), ("east", 0.0, 90.0), ("south", 0.0, 180.0), ("west", 0.0, 270.0), ("up", 270.0, 0.0), ("down", 90.0, 0.0)] {
907 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
908 }
909 json!({ "multipart": parts })
910 }
911 };
912 emit(cx, format!("blockstates/{p}.json"), &blockstate);
913
914 let item_model = match &parts.item_model {
916 None => Some(json!({ "parent": main })),
917 Some(im) => model(cx, loc, &root.key("item_model"), im, true).map(|m| m.json),
918 };
919 if let Some(im) = item_model {
920 emit(cx, format!("models/item/{p}.json"), &im);
921 }
922 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
923
924 if !drops.is_null() {
925 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
926 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
927 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
928 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set drops: \"none\""));
929 }
930 out.outputs.push((full, pretty(&drops)));
931 }
932 for tag in &entry.block.tools {
935 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", tag));
936 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone());
937 }
938 Some(entry)
939}
940
941fn drops_table(item: &str, count: Option<yog_formats::content::DropCount>) -> Value {
943 let mut entry = json!({ "type": "minecraft:item", "name": item });
944 if let Some(c) = count {
945 let count = match c {
946 yog_formats::content::DropCount::Exactly(n) => num_value(n),
947 yog_formats::content::DropCount::Between(min, max) => json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) }),
948 };
949 entry["functions"] = json!([{ "function": "minecraft:set_count", "count": count }]);
950 }
951 json!({
952 "type": "minecraft:block",
953 "pools": [{
954 "rolls": 1,
955 "entries": [entry],
956 "conditions": [{ "condition": "minecraft:survives_explosion" }]
957 }]
958 })
959}
960
961#[derive(Debug, Clone)]
965enum RecipeKind {
966 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
969 Shapeless(Vec<Vec<String>>),
971 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
973 Stonecutting(Vec<String>),
974 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
975 Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
979}
980
981struct BookFile {
983 id: String,
984 data: Vec<u8>,
987}
988
989fn dimension_path(id: &str) -> String {
991 format!("dimensions/{}.ydata", id.replace(':', "__"))
992}
993
994impl BookFile {
995 fn path(&self) -> String {
997 format!("books/{}.ydata", self.id.replace(':', "__"))
998 }
999}
1000
1001fn compile_book(
1010 cx: &mut Ctx,
1011 text: &str,
1012 mod_ns: Option<&str>,
1013 known: &refs::Known,
1014 layouts: &[yog_book::layout::RecipeLayout],
1015 recipe_kinds: &HashMap<String, String>,
1016 page_types: &[yog_book::page_type::PageType],
1017) -> Option<BookFile> {
1018 use refs::RefKind;
1019 use yog_data::schema::{Resolve, Text};
1020 use yog_data::{Path, Ref, Res};
1021
1022 struct Check<'a> {
1023 known: &'a refs::Known,
1024 texts: &'a HashMap<String, (String, String)>,
1025 entries: HashSet<String>,
1026 categories: HashSet<String>,
1027 text_refs: bool,
1028 }
1029
1030 impl Resolve for Check<'_> {
1031 fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
1032 let own = match r.kind.as_str() {
1033 "entry" => Some(&self.entries),
1034 "category" => Some(&self.categories),
1035 _ => None,
1036 };
1037 match (own, RefKind::from_word(&r.kind)) {
1038 (Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
1039 (Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
1040 (None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
1041 (None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
1042 }
1043 }
1044
1045 fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
1046 match t {
1047 Text::Literal(s) => Ok(s.clone()),
1048 Text::NameOf(r) => {
1050 if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
1051 return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
1052 }
1053 self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
1054 let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
1055 self.text_refs = true;
1056 Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
1057 }
1058 Text::Lang(key) => {
1060 self.text_refs = true;
1061 Ok(key.clone())
1062 }
1063 Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
1064 Some((key, _)) => {
1065 self.text_refs = true;
1066 Ok(key.clone())
1067 }
1068 None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1069 },
1070 }
1071 }
1072
1073 fn words(&mut self, r: &Res) -> Option<String> {
1074 self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
1075 }
1076 }
1077
1078 let doc = match yog_data::text::parse(text) {
1080 Ok(d) => d,
1081 Err(e) => {
1082 cx.err(e.at.line as usize, e.message);
1083 return None;
1084 }
1085 };
1086 let ids = |key: &str| -> HashSet<String> {
1087 doc.value.get(key).and_then(|v| v.as_list()).unwrap_or_default().iter().filter_map(|e| e.get("id")?.as_str().map(str::to_string)).collect()
1088 };
1089 let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
1090 let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
1091 Ok(read) => read.value,
1092 Err(errors) => {
1093 for e in errors.0 {
1094 let line = e.at.map_or(1, |a| a.line as usize);
1095 if e.path.is_root() {
1096 cx.err(line, e.message);
1097 } else {
1098 cx.err(line, format!("{}: {}", e.path, e.message));
1099 }
1100 }
1101 return None;
1102 }
1103 };
1104 if !book.id.contains(':') {
1105 book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
1106 }
1107 let ns = mod_ns.unwrap_or("minecraft");
1109 let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
1110 .chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
1111 .filter(|(_, id)| !id.contains(':'))
1112 .map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
1113 .collect();
1114 if !unnamespaced.is_empty() {
1115 for e in unnamespaced {
1116 cx.err(1, e);
1117 }
1118 return None;
1119 }
1120 if !valid_id(&book.id) {
1121 cx.err(1, format!("book id `{}`: not a valid id", book.id));
1122 return None;
1123 }
1124 let mut used: Vec<yog_book::page_type::PageType> = Vec::new();
1127 let mut fields_ok = true;
1128 for (e, entry) in book.entries.iter_mut().enumerate() {
1129 for (p, page) in entry.pages.iter_mut().enumerate() {
1130 let yog_book::BookPage::Custom(c) = page else { continue };
1131 use yog_book::page_type::PropKind as K;
1132 use yog_data::with::With;
1133 let at = Path::root().key("entries").index(e).key("pages").index(p);
1134 let line = |path: &Path| doc.locate(text, path).line as usize;
1135 let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
1136 if c.kind.split(':').next() == mod_ns {
1137 cx.err(line(&at.key("type")), format!("{}.type: no page type {} — a .yui component with book_page=\"{}\"", at, c.kind, c.kind));
1138 fields_ok = false;
1139 } else {
1140 cx.warn(line(&at.key("type")), format!("{}.type: {} is another mod's page type — not checked here", at, c.kind));
1141 }
1142 continue;
1143 };
1144 for (k, _) in c.fields.iter() {
1145 if k != "anchor" && !t.properties.iter().any(|p| p.name == k) {
1146 let names: Vec<&str> = t.properties.iter().map(|p| p.name.as_str()).collect();
1147 cx.err(line(&at.key(k)), format!("{}: {} has no field `{k}` ({})", at.key(k), c.kind, names.join(", ")));
1148 fields_ok = false;
1149 }
1150 }
1151 for prop in &t.properties {
1152 let path = at.key(&prop.name);
1153 let Some(v) = c.fields.get(&prop.name).cloned() else {
1154 if prop.default.is_none() {
1155 cx.err(line(&at), format!("{at}: missing `{}` ({})", prop.name, c.kind));
1156 fields_ok = false;
1157 }
1158 continue;
1159 };
1160 let arena = yog_data::Bump::new();
1161 let r = v.borrow(&arena);
1162 let mut reader = yog_data::Reader::with_resolver(&mut check);
1163 let read = match prop.kind {
1164 K::Text => yog_data::with::TextOf::read(&r, &mut reader).map(yog_data::Value::Str),
1165 K::BookText => yog_book::refs::BookText::read(&r, &mut reader).map(yog_data::Value::Str),
1166 K::Item => yog_data::with::RefTo::<(yog_data::kinds::Item, yog_data::kinds::Block)>::read(&r, &mut reader).map(yog_data::Value::Str),
1167 K::Image => yog_book::refs::Icon::read(&r, &mut reader).map(yog_data::Value::Str),
1168 K::Number => <f64 as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Float),
1169 K::Bool => <bool as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Bool),
1170 K::Data => Some(v.clone()),
1171 };
1172 let problems = std::mem::take(&mut reader.problems);
1173 drop(reader);
1174 for pr in problems {
1175 cx.err(line(&path), format!("{path}: {}", pr.message));
1176 fields_ok = false;
1177 }
1178 if let Some(out) = read {
1179 c.fields.insert(prop.name.as_str(), out);
1180 }
1181 }
1182 if !used.contains(t) {
1183 used.push(t.clone());
1184 }
1185 }
1186 }
1187 if !fields_ok {
1188 return None;
1189 }
1190 book.page_types = used;
1191 book.i18n |= check.text_refs;
1192 let mut ok = true;
1194 for (e, entry) in book.entries.iter().enumerate() {
1195 for (p, page) in entry.pages.iter().enumerate() {
1196 if let yog_book::BookPage::Recipe { recipe, .. } = page
1197 && let Some(kind) = recipe_kinds.get(recipe)
1198 && yog_book::layout::find(layouts, kind).is_none()
1199 {
1200 let path = yog_data::Path::root().key("entries").index(e).key("pages").index(p).key("recipe");
1201 let short = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
1202 cx.err(
1203 doc.locate(text, &path).line as usize,
1204 format!("{path}: no layout for recipe kind {kind} — lay it out in a .yui: <Component recipe_layout=\"{short}\" width=\"…\" height=\"…\"> (see yog_book::layout)"),
1205 );
1206 ok = false;
1207 }
1208 }
1209 }
1210 if !ok {
1211 return None;
1212 }
1213 book.layouts = layouts.to_vec();
1214 Some(BookFile { id: book.id.clone(), data: yog_data::binary::encode(&yog_data::Schema::write(&book)) })
1215}
1216
1217fn compile_layout(
1221 cx: &mut Ctx,
1222 text: &str,
1223 mod_ns: Option<&str>,
1224 known: &refs::Known,
1225 ores: &mut Vec<OreOut>,
1226 dimensions: &mut Vec<(String, Vec<u8>)>,
1227 tables: &mut Vec<table::Table>,
1228) -> Option<yog_formats::def::RecipeKindDef> {
1229 use yog_formats::def::Def;
1230 let doc = match yog_data::text::parse(text) {
1231 Ok(d) => d,
1232 Err(e) => {
1233 cx.err(e.at.line as usize, e.message);
1234 return None;
1235 }
1236 };
1237 let read = match yog_data::schema::from_doc::<Def>(text, &doc) {
1238 Ok(r) => r,
1239 Err(errors) => {
1240 for e in errors.0 {
1241 let l = e.at.map_or(1, |a| a.line as usize);
1242 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1243 }
1244 return None;
1245 }
1246 };
1247 let mut ok = true;
1248 for f in &read.refs {
1249 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1250 && let Err(e) = known.check(kind, &f.value.id)
1251 {
1252 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1253 ok = false;
1254 }
1255 }
1256 let ns = mod_ns.unwrap_or("minecraft");
1257 let kind = match read.value {
1258 Def::RecipeKind(mut k) => {
1259 if !k.id.contains(':') {
1260 k.id = format!("{ns}:{}", k.id);
1261 }
1262 if k.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1263 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own kind, `{ns}:<kind>`", k.id));
1264 ok = false;
1265 }
1266 k
1267 }
1268 Def::Ore(o) => {
1269 if ok {
1270 ores.push(ore(o, ns));
1271 }
1272 return None;
1273 }
1274 Def::Dimension(d) => {
1275 if ok {
1276 dimensions.push(dimension(d, ns));
1277 }
1278 return None;
1279 }
1280 Def::Table(t) => {
1281 match table::check(&cx.file, t, cx.refs, known) {
1282 Ok(t) => tables.push(t),
1283 Err(errors) => {
1284 for e in errors {
1285 let path = e.split_once(':').map_or("", |(p, _)| p);
1287 let at = path.split('.').fold(yog_data::Path::root(), |at, k| at.key(k));
1288 cx.err(doc.locate(text, &at).line as usize, e);
1289 }
1290 }
1291 }
1292 return None;
1293 }
1294 _ => return None,
1295 };
1296 ok.then_some(kind)
1297}
1298
1299fn recipe_layouts(
1303 root: &Path,
1304 ns: &str,
1305 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
1306 known: &refs::Known,
1307 errors: &mut Vec<String>,
1308) -> Vec<yog_book::layout::RecipeLayout> {
1309 use yog_book::layout::LayoutPart;
1310 let mut laid_out: HashMap<String, String> = HashMap::new();
1311 let mut out = Vec::new();
1312 for (shown, l) in crate::book_page::find_layouts(root, ns, errors) {
1313 let mut ok = true;
1314 for k in &l.kinds {
1315 let game = yog_formats::recipe::TYPES.contains(&k.as_str());
1316 if !game && !kinds.contains_key(k) {
1317 errors.push(format!(
1318 "{shown}: recipe_layout: `{k}`: one of {}, or a kind the mod declares (a .ydef of kind recipe_kind)",
1319 yog_formats::recipe::TYPES.join(", ")
1320 ));
1321 ok = false;
1322 }
1323 if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
1324 errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
1325 ok = false;
1326 }
1327 }
1328 for part in &l.parts {
1329 match part {
1330 LayoutPart::Slots { slot, .. } => {
1331 for k in l.kinds.iter().filter_map(|k| kinds.get(k)) {
1332 if !k.slots.0.iter().any(|(s, _)| s == slot) {
1333 let all: Vec<&str> = k.slots.0.iter().map(|(s, _)| s.as_str()).collect();
1334 errors.push(format!("{shown}: <RecipeSlot slot=\"{slot}\">: {} has no slot `{slot}` — {}", k.id, all.join(", ")));
1335 ok = false;
1336 }
1337 }
1338 }
1339 LayoutPart::Item { item, .. } => {
1340 if let Err(e) = known.check(refs::RefKind::Item, item) {
1341 errors.push(format!("{shown}: <ItemSlot item=\"{item}\">: {e}"));
1342 ok = false;
1343 }
1344 }
1345 _ => {}
1346 }
1347 }
1348 if ok {
1349 out.push(l);
1350 }
1351 }
1352 out
1353}
1354
1355fn dimension(d: yog_formats::def::DimensionDef, ns: &str) -> (String, Vec<u8>) {
1359 use yog_data::{Map, Schema, Value as V};
1360 let id = if d.id.contains(':') { d.id.clone() } else { format!("{ns}:{}", d.id) };
1361 let mut m = Map::new();
1362 m.insert("id", V::from(id.as_str()));
1363 m.insert("dimension_type", d.dimension_type.write());
1364 m.insert("biome", V::from(d.biome.map_or("minecraft:plains".to_string(), |b| b.id).as_str()));
1365 m.insert("structures", V::Bool(d.structures));
1366 m.insert("features", V::Bool(d.features));
1367 m.insert("mob_spawning", V::Bool(d.mob_spawning));
1368 if let Some(t) = &d.terrain {
1369 m.insert("generator_type", V::from(id.as_str()));
1370 m.insert("terrain", t.write());
1371 } else if let Some(g) = &d.generator {
1372 m.insert("generator_type", V::from(if g.contains(':') { g.clone() } else { format!("{ns}:{g}") }.as_str()));
1373 }
1374 (id, yog_data::binary::encode(&V::Map(m)))
1375}
1376
1377type OreOut = (Vec<Output>, (String, String, String));
1379
1380fn ore(o: yog_formats::def::OreDef, ns: &str) -> OreOut {
1384 use serde_json::json;
1385 let p = o.id.split_once(':').map_or(o.id.as_str(), |(_, p)| p).to_string();
1386 let id = format!("{ns}:{p}");
1387 let targets: Vec<Value> = o
1388 .targets
1389 .iter()
1390 .map(|t| {
1391 let target = match t.replaces.strip_prefix('#') {
1392 Some(tag) => json!({ "predicate_type": "minecraft:tag_match", "tag": tag }),
1393 None => json!({ "predicate_type": "minecraft:block_match", "block": t.replaces }),
1394 };
1395 json!({ "target": target, "state": { "Name": t.block.id } })
1396 })
1397 .collect();
1398 let configured = json!({
1399 "type": "minecraft:ore",
1400 "config": { "size": o.size, "discard_chance_on_air_exposure": o.air_exposure, "targets": targets }
1401 });
1402 let shape = match o.height.shape {
1403 yog_formats::def::HeightShape::Uniform => "minecraft:uniform",
1404 yog_formats::def::HeightShape::Triangle => "minecraft:trapezoid",
1405 };
1406 let placed = json!({
1407 "feature": id,
1408 "placement": [
1409 { "type": "minecraft:count", "count": o.per_chunk },
1410 { "type": "minecraft:in_square" },
1411 { "type": "minecraft:height_range", "height": { "type": shape, "min_inclusive": { "absolute": o.height.min }, "max_inclusive": { "absolute": o.height.max } } },
1412 { "type": "minecraft:biome" }
1413 ]
1414 });
1415 let biomes = o.biomes.map(|b| b.id).unwrap_or_else(|| "minecraft:is_overworld".into());
1416 let modifier = |kind: &str| json!({ "type": kind, "biomes": format!("#{biomes}"), "features": id, "step": "underground_ores" });
1417 let mut files: Vec<Output> = Vec::new();
1418 let mut put = |path: String, v: Value| files.push((path, pretty(&v)));
1419 put(format!("data/{ns}/worldgen/configured_feature/{p}.json"), configured);
1420 put(format!("data/{ns}/worldgen/placed_feature/{p}.json"), placed);
1421 for (dir, kind) in [("when/forge/any", "forge:add_features"), ("when/neoforge/1.20", "forge:add_features")] {
1422 put(format!("{dir}/data/{ns}/forge/biome_modifier/{p}.json"), modifier(kind));
1423 }
1424 put(format!("when/neoforge/1.21/data/{ns}/neoforge/biome_modifier/{p}.json"), modifier("neoforge:add_features"));
1425 (files, (id, biomes, "underground_ores".into()))
1426}
1427
1428#[derive(Debug, Clone)]
1430struct Recipe {
1431 id: String,
1432 ty: String,
1434 kind: RecipeKind,
1435 result: String,
1436 count: u32,
1437 group: Option<String>,
1438 category: Option<String>,
1439 requires: Vec<String>,
1441}
1442
1443impl Recipe {
1444 fn registration(&self) -> String {
1446 let list = |items: &[String]| {
1447 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
1448 format!("vec![{}]", quoted.join(", "))
1449 };
1450 let kind = match &self.kind {
1451 RecipeKind::Shaped { pattern, key } => {
1452 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
1453 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
1454 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
1455 }
1456 RecipeKind::Shapeless(ingredients) => {
1457 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
1458 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
1459 }
1460 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
1461 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
1462 list(input),
1463 float(*experience)
1464 ),
1465 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
1466 RecipeKind::Custom { kind, slots, values } => {
1467 let mut o = Map::new();
1469 o.insert("type".into(), kind.clone().into());
1470 o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
1471 if !values.is_empty() {
1472 o.insert("values".into(), Value::Object(values.clone()));
1473 }
1474 o.insert("result".into(), self.result.clone().into());
1475 if self.count != 1 {
1476 o.insert("count".into(), self.count.into());
1477 }
1478 if let Some(g) = &self.group {
1479 o.insert("group".into(), g.clone().into());
1480 }
1481 return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
1482 }
1483 RecipeKind::Smithing { template, base, addition } => format!(
1484 "Smithing {{ template: {}, base: {}, addition: {} }}",
1485 list(template),
1486 list(base),
1487 list(addition)
1488 ),
1489 };
1490 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
1491 format!(
1492 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
1493 self.id,
1494 self.result,
1495 self.count,
1496 opt(&self.group),
1497 opt(&self.category)
1498 )
1499 }
1500}
1501
1502fn compile_recipe(
1506 cx: &mut Ctx,
1507 text: &str,
1508 path: &Path,
1509 mod_ns: Option<&str>,
1510 known: &refs::Known,
1511 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
1512) -> Option<Recipe> {
1513 use yog_formats::recipe::{Body, RecipeFile};
1514 use yog_data::Path as At;
1515
1516 let doc = match yog_data::text::parse(text) {
1517 Ok(d) => d,
1518 Err(e) => {
1519 cx.err(e.at.line as usize, e.message);
1520 return None;
1521 }
1522 };
1523 let line = |p: &At| doc.locate(text, p).line as usize;
1524 let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
1525 Ok(r) => r,
1526 Err(errors) => {
1527 for e in errors.0 {
1528 let l = e.at.map_or(1, |a| a.line as usize);
1529 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1530 }
1531 return None;
1532 }
1533 };
1534 let mut ok = true;
1535 for f in &read.refs {
1536 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1537 && let Err(e) = known.check(kind, &f.value.id)
1538 {
1539 cx.err(line(&f.path), format!("{}: {e}", f.path));
1540 ok = false;
1541 }
1542 }
1543 let RecipeFile { common: c, body } = read.value;
1544 let ty = match &body {
1545 Body::Shaped(_) => "shaped".to_string(),
1546 Body::Shapeless(_) => "shapeless".to_string(),
1547 Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
1548 Body::Stonecutting(_) => "stonecutting".to_string(),
1549 Body::Smithing(_) => "smithing".to_string(),
1550 };
1551 let root = At::root();
1552 if let Some(f) = c.format
1553 && f != 1
1554 {
1555 cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
1556 ok = false;
1557 }
1558 let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
1560 let id_line = line(&root.key("id"));
1561 let (ns, p) = match raw.split_once(':') {
1562 Some((ns, p)) => (ns.to_string(), p.to_string()),
1563 None => match mod_ns {
1564 Some(ns) => (ns.to_string(), raw.clone()),
1565 None => {
1566 cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
1567 return None;
1568 }
1569 },
1570 };
1571 if !valid_namespace(&ns) || !valid_path(&p) {
1572 cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
1573 return None;
1574 }
1575 if !(1..=64).contains(&c.count) {
1576 cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
1577 ok = false;
1578 }
1579 let kind = match body {
1580 Body::Shaped(s) => {
1581 let width = s.pattern.first().map_or(0, |r| r.chars().count());
1582 if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
1583 cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
1584 return None;
1585 }
1586 let mut key = Vec::new();
1587 for (k, ing) in &s.key.0 {
1588 let at = line(&root.key("key").key(k));
1589 let mut chars = k.chars();
1590 match (chars.next(), chars.next()) {
1591 (Some(ch), None) if ch != ' ' => {
1592 if !s.pattern.iter().any(|r| r.contains(ch)) {
1593 cx.warn(at, format!("key {k:?} isn't used by the pattern"));
1594 }
1595 key.push((ch, ing.ids()));
1596 }
1597 _ => {
1598 cx.err(at, format!("key {k:?}: one character other than space"));
1599 ok = false;
1600 }
1601 }
1602 }
1603 for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1604 if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
1605 cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
1606 return None;
1607 }
1608 }
1609 RecipeKind::Shaped { pattern: s.pattern, key }
1610 }
1611 Body::Shapeless(s) => {
1612 if s.ingredients.is_empty() || s.ingredients.len() > 9 {
1613 cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
1614 return None;
1615 }
1616 RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
1617 }
1618 Body::Cooking(ty, k) => {
1619 let (method, default_time) = match ty.as_str() {
1620 "smelting" => ("Smelting", 200),
1621 "blasting" => ("Blasting", 100),
1622 "smoking" => ("Smoking", 100),
1623 _ => ("Campfire", 600),
1624 };
1625 if !(0.0..=1000.0).contains(&k.experience) {
1626 cx.err(line(&root.key("experience")), "experience: 0–1000");
1627 ok = false;
1628 }
1629 let cook_time = k.cook_time.unwrap_or(default_time);
1630 if !(1..=32767).contains(&cook_time) {
1631 cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
1632 ok = false;
1633 }
1634 RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
1635 }
1636 Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
1637 Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
1638 Body::Custom(kind, k) => {
1639 let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
1641 if !own {
1642 cx.err(
1643 line(&root.key("type")),
1644 format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
1645 );
1646 return None;
1647 }
1648 if c.category.is_some() {
1649 cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
1650 ok = false;
1651 }
1652 let Some(def) = kinds.get(&kind) else {
1654 let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
1655 cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
1656 return None;
1657 };
1658 for (name, ings) in &k.slots.0 {
1659 let at = root.key("slots").key(name);
1660 match def.slots.0.iter().find(|(n, _)| n == name) {
1661 None => {
1662 let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
1663 cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
1664 ok = false;
1665 }
1666 Some((_, spec)) if ings.len() > spec.max as usize => {
1667 cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
1668 ok = false;
1669 }
1670 _ => {}
1671 }
1672 }
1673 for (name, spec) in &def.slots.0 {
1674 if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
1675 cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
1676 ok = false;
1677 }
1678 }
1679 for (name, v) in &k.values.0 {
1680 let at = root.key("values").key(name);
1681 let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
1682 None => {
1683 let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
1684 cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
1685 ok = false;
1686 continue;
1687 }
1688 Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
1689 Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
1690 Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
1691 };
1692 if !fits {
1693 cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
1694 ok = false;
1695 }
1696 }
1697 let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
1698 let values = k.values.0.iter().map(|(name, v)| (name.clone(), serde_json::from_str(&yog_data::text::to_string(v)).unwrap_or(Value::Null))).collect();
1699 RecipeKind::Custom { kind, slots, values }
1700 }
1701 };
1702 ok.then(|| Recipe {
1703 id: format!("{ns}:{p}"),
1704 ty,
1705 kind,
1706 result: c.result.id,
1707 count: c.count,
1708 group: c.group,
1709 category: c.category,
1710 requires: c.requires,
1711 })
1712}
1713
1714struct Defs {
1717 order: BTreeMap<String, Vec<String>>,
1718 sound_groups: Vec<yog_formats::def::SoundGroup>,
1719 tool_materials: Vec<yog_formats::def::ToolMaterial>,
1720 armor_materials: Vec<yog_formats::def::ArmorMaterial>,
1721 biome_features: Vec<(String, String, String)>,
1723 dimensions: Vec<String>,
1725 tables: Vec<table::Table>,
1727}
1728
1729impl Defs {
1730 fn level(&self, id: &str) -> Option<(&str, usize)> {
1732 self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
1733 }
1734}
1735
1736fn read_defs(defs: &[(String, String)], mod_ns: Option<&str>, errors: &mut Vec<String>) -> Defs {
1739 let mut order: BTreeMap<String, Vec<String>> = BTreeMap::new();
1740 let mut sound_groups: Vec<yog_formats::def::SoundGroup> = Vec::new();
1741 order.insert(yog_formats::def::TOOL_LADDER.into(), yog_formats::def::TOOL_LEVELS.iter().map(|l| format!("minecraft:{l}")).collect());
1742 let ns = mod_ns.unwrap_or("minecraft");
1743 let full = |id: &str| if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") };
1744 let mut levels: Vec<(String, usize, String, String, Option<String>)> = Vec::new();
1746 let mut tools: Vec<(String, yog_formats::def::ToolMaterialDef)> = Vec::new();
1747 let mut armor_materials: Vec<yog_formats::def::ArmorMaterial> = Vec::new();
1748 let repair = |r: &yog_data::ItemIdOrTag| match r {
1749 yog_data::OneOrTag::One(i) => i.id.clone(),
1750 yog_data::OneOrTag::Tag(t) => format!("#{}", t.id),
1751 };
1752 for (file, text) in defs {
1753 let doc = match yog_data::text::parse(text) {
1754 Ok(d) => d,
1755 Err(e) => {
1756 errors.push(format!("{file}:{}: {}", e.at.line, e.message));
1757 continue;
1758 }
1759 };
1760 match yog_data::schema::from_doc::<yog_formats::def::Def>(text, &doc) {
1761 Ok(r) => match r.value {
1762 yog_formats::def::Def::Ladder { id } => {
1763 if order.insert(full(&id), Vec::new()).is_some() {
1764 errors.push(format!("{file}:1: ladder {} is defined twice", full(&id)));
1765 }
1766 }
1767 yog_formats::def::Def::Level { id, ladder, above } => levels.push((file.clone(), doc.locate(text, &At::root().key("id")).line as usize, full(&id), ladder, above)),
1768 yog_formats::def::Def::SoundGroup(mut g) => {
1769 for s in [&mut g.id, &mut g.break_sound, &mut g.step, &mut g.place, &mut g.hit, &mut g.fall] {
1771 *s = full(s);
1772 }
1773 if yog_formats::def::SoundGroup::built_in(&g.id).is_some() {
1774 errors.push(format!("{file}:1: sound group {} is the game's", g.id));
1775 } else if sound_groups.iter().any(|x| x.id == g.id) {
1776 errors.push(format!("{file}:1: sound group {} is defined twice", g.id));
1777 } else {
1778 sound_groups.push(g);
1779 }
1780 }
1781 yog_formats::def::Def::RecipeKind(_) | yog_formats::def::Def::Ore(_) | yog_formats::def::Def::Dimension(_) | yog_formats::def::Def::Table(_) => {}
1783 yog_formats::def::Def::ToolMaterial(mut m) => {
1785 m.id = full(&m.id);
1786 tools.push((file.clone(), m));
1787 }
1788 yog_formats::def::Def::ArmorMaterial(m) => {
1789 let id = full(&m.id);
1790 if yog_formats::def::ArmorMaterial::built_in(&id).is_some() || armor_materials.iter().any(|x| x.id == id) {
1791 errors.push(format!("{file}:1: armor material {id} is the game's or defined twice"));
1792 continue;
1793 }
1794 armor_materials.push(yog_formats::def::ArmorMaterial {
1795 texture: m.texture.as_deref().map(full).unwrap_or_else(|| id.clone()),
1796 equip_sound: full(&m.equip_sound),
1797 durability: m.durability,
1798 defense: m.defense,
1799 enchantability: m.enchantability,
1800 toughness: m.toughness,
1801 knockback_resistance: m.knockback_resistance,
1802 repair: repair(&m.repair),
1803 id,
1804 });
1805 }
1806 },
1807 Err(es) => {
1808 for e in es.0 {
1809 let l = e.at.map_or(1, |a| a.line as usize);
1810 errors.push(if e.path.is_root() { format!("{file}:{l}: {}", e.message) } else { format!("{file}:{l}: {}: {}", e.path, e.message) });
1811 }
1812 }
1813 }
1814 }
1815 loop {
1817 let before = levels.len();
1818 levels.retain(|(file, line, id, ladder, above)| {
1819 let Some(list) = order.get_mut(ladder) else {
1820 errors.push(format!("{file}:{line}: level {id}: no ladder {ladder}"));
1821 return false;
1822 };
1823 let at = match above {
1824 None => 0,
1825 Some(a) => match list.iter().position(|x| x == a) {
1826 Some(i) => i + 1,
1827 None => return true, },
1829 };
1830 if list.contains(id) {
1831 errors.push(format!("{file}:{line}: level {id} is defined twice"));
1832 } else {
1833 list.insert(at, id.clone());
1834 }
1835 false
1836 });
1837 if levels.is_empty() || levels.len() == before {
1838 break;
1839 }
1840 }
1841 for (file, line, id, ladder, above) in levels {
1842 let above = above.unwrap_or_default();
1843 errors.push(format!("{file}:{line}: level {id}: above {above}, which isn't a level of {ladder} (or the levels go round in a circle)"));
1844 }
1845 let ladder = order.get(yog_formats::def::TOOL_LADDER).cloned().unwrap_or_default();
1850 let mut tool_materials: Vec<yog_formats::def::ToolMaterial> = Vec::new();
1851 for (file, m) in tools {
1852 if yog_formats::def::ToolMaterial::built_in(&m.id).is_some() || tool_materials.iter().any(|x| x.id == m.id) {
1853 errors.push(format!("{file}:1: tool material {} is the game's or defined twice", m.id));
1854 continue;
1855 }
1856 let Some(at) = ladder.iter().position(|l| l == &m.level) else {
1857 errors.push(format!("{file}:1: level: {} isn't a level of the tool ladder", m.level));
1858 continue;
1859 };
1860 let place = |v: &str| ladder.iter().position(|x| x == &format!("minecraft:{v}")).unwrap_or(0);
1861 let game = ["wood", "stone", "iron", "diamond", "netherite"];
1862 let (level_index, incorrect) = match game.iter().position(|v| place(v) >= at) {
1863 Some(i) => (i as u32, format!("minecraft:incorrect_for_{}_tool", if game[i] == "wood" { "wooden" } else { game[i] })),
1864 None => {
1866 let (n, p) = m.level.split_once(':').unwrap_or(("minecraft", &m.level));
1867 (5, format!("{n}:incorrect_for_{p}_tool"))
1868 }
1869 };
1870 tool_materials.push(yog_formats::def::ToolMaterial {
1871 id: m.id,
1872 durability: m.durability,
1873 speed: m.speed,
1874 attack_damage: m.attack_damage,
1875 enchantability: m.enchantability,
1876 level: m.level,
1877 repair: repair(&m.repair),
1878 level_index,
1879 incorrect,
1880 });
1881 }
1882 Defs { order, sound_groups, tool_materials, armor_materials, biome_features: Vec::new(), dimensions: Vec::new(), tables: Vec::new() }
1883}
1884
1885fn tool_level_tags(id: &str, level: &str, ladders: &Defs, out: &mut Compiled, err: &mut dyn FnMut(String), warn: &mut dyn FnMut(String)) {
1890 let Some((ladder, at)) = ladders.level(level) else { return };
1891 if ladder != yog_formats::def::TOOL_LADDER {
1892 err(format!("{level} is a level of {ladder}, not of the tool ladder ({})", yog_formats::def::TOOL_LADDER));
1893 return;
1894 }
1895 let tools = &ladders.order[yog_formats::def::TOOL_LADDER];
1896 let place = |l: &str| tools.iter().position(|x| x == &format!("minecraft:{l}")).unwrap_or(0);
1897 let able = ["wood", "stone", "iron", "diamond", "netherite"].into_iter().find(|v| place(v) >= at);
1899 let mut tag = |path: String| {
1900 out.tags.entry(path).or_default().insert(id.to_string());
1901 };
1902 match able {
1903 Some("wood") => {}
1904 Some(v @ ("stone" | "iron" | "diamond")) => tag(format!("data/minecraft/tags/block/needs_{v}_tool.json")),
1905 _ => {
1906 tag("data/minecraft/tags/block/needs_diamond_tool.json".into());
1908 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json".into());
1909 tag(format!("when/fabric/1.20/data/fabric/tags/block/needs_tool_level_{}.json", if able.is_some() { 4 } else { 5 }));
1910 for loader in ["forge", "neoforge"] {
1911 tag(format!("when/{loader}/1.20/data/forge/tags/block/needs_netherite_tool.json"));
1912 }
1913 if able.is_none() {
1914 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_netherite_tool.json".into());
1915 warn("above netherite: on Forge and NeoForge 1.20 a netherite tool still mines it (their tiers stop at netherite)".into());
1916 }
1917 }
1918 }
1919}
1920
1921fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1924 let mut out = BTreeSet::new();
1925 let Ok(entries) = std::fs::read_dir(dir) else {
1926 return out;
1927 };
1928 for e in entries.flatten() {
1929 let path = e.path();
1930 if path.is_dir() {
1931 out.extend(inventory_screens(&path));
1932 continue;
1933 }
1934 if path.extension().is_none_or(|x| x != "yui") {
1935 continue;
1936 }
1937 let Ok(text) = std::fs::read_to_string(&path) else { continue };
1938 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
1939 continue;
1940 }
1941 if let Some(screen) = text.find("<Screen")
1942 && let Some(at) = text[screen..].find(" id=\"")
1943 {
1944 let rest = &text[screen + at + 5..];
1945 if let Some(end) = rest.find('"') {
1946 out.insert(rest[..end].to_string());
1947 }
1948 }
1949 }
1950 out
1951}
1952
1953pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1956 let mut out = Compiled::default();
1957 let files = find(root);
1958 if files.is_empty() {
1959 return Ok(out);
1960 }
1961 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1962 let mut errors: Vec<String> = Vec::new();
1963 let mut warnings: Vec<String> = Vec::new();
1964 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1965 let mut generated: BTreeMap<String, String> = BTreeMap::new();
1966 let mut entries: Vec<Entry> = Vec::new();
1967 let mut ids: HashMap<String, String> = HashMap::new();
1968 let mut recipes: Vec<Recipe> = Vec::new();
1969 let mut recipe_ids: HashMap<String, String> = HashMap::new();
1970 let mut books: Vec<BookFile> = Vec::new();
1971 let mut book_ids: HashMap<String, String> = HashMap::new();
1972 let mut inventories: Vec<(String, usize, String)> = Vec::new();
1973 let mut pending_books: Vec<(String, String)> = Vec::new();
1974 let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
1975 let mut pending_layouts: Vec<(String, String)> = Vec::new();
1976 let mut pending_defs: Vec<(String, String)> = Vec::new();
1977 let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
1978 for path in &files {
1979 let shown = display(root, path);
1980 let Some(kind) = Kind::of(path) else { continue };
1981 let text = match std::fs::read_to_string(path) {
1982 Ok(t) => t,
1983 Err(e) => {
1984 errors.push(format!("{shown}: {e}"));
1985 continue;
1986 }
1987 };
1988 if kind == Kind::Book {
1989 pending_books.push((shown.clone(), text));
1991 continue;
1992 }
1993 if kind == Kind::Recipe {
1994 pending_recipes.push((shown.clone(), text, path.clone()));
1996 continue;
1997 }
1998 if kind == Kind::Def {
1999 let of = yog_data::text::parse_value(&text).ok().and_then(|v| v.get("kind").and_then(|k| k.as_str()).map(str::to_string));
2001 if matches!(of.as_deref(), Some("recipe_kind" | "ore" | "dimension" | "table")) {
2002 pending_layouts.push((shown.clone(), text));
2003 } else {
2004 pending_defs.push((shown.clone(), text));
2005 }
2006 continue;
2007 }
2008 let mut cx = Ctx {
2009 root,
2010 refs,
2011 file: shown.clone(),
2012 ns: mod_ns.unwrap_or("minecraft").to_string(),
2013 errors: &mut errors,
2014 warnings: &mut warnings,
2015 textures: &mut textures,
2016 inventories: &mut inventories,
2017 deferred: &mut deferred,
2018 };
2019 if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
2020 {
2021 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2022 errors.push(format!(
2023 "{shown}: id {} is also defined by {other}",
2024 entry.id
2025 ));
2026 }
2027 entries.push(entry);
2028 }
2029 }
2030 let manifest = crate::manifest::YogToml::read(&root.join("yog.toml")).ok();
2032 let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
2033 let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
2034 let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
2035 for e in &entries {
2036 known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2037 }
2038 let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
2041 let mut ores: Vec<OreOut> = Vec::new();
2042 let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
2043 let mut tables: Vec<table::Table> = Vec::new();
2044 for (shown, text) in &pending_layouts {
2045 let mut cx = Ctx {
2046 root,
2047 refs,
2048 file: shown.clone(),
2049 ns: mod_ns.unwrap_or("minecraft").to_string(),
2050 errors: &mut errors,
2051 warnings: &mut warnings,
2052 textures: &mut textures,
2053 inventories: &mut inventories,
2054 deferred: &mut deferred,
2055 };
2056 let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables) else { continue };
2057 if recipe_defs.contains_key(&k.id) {
2058 errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
2059 }
2060 recipe_defs.insert(k.id.clone(), k);
2061 }
2062 let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
2064 for (shown, text, path) in &pending_recipes {
2065 let mut cx = Ctx {
2066 root,
2067 refs,
2068 file: shown.clone(),
2069 ns: mod_ns.unwrap_or("minecraft").to_string(),
2070 errors: &mut errors,
2071 warnings: &mut warnings,
2072 textures: &mut textures,
2073 inventories: &mut inventories,
2074 deferred: &mut deferred,
2075 };
2076 if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs) {
2077 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2078 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2079 }
2080 recipes.push(r);
2081 }
2082 }
2083 for r in &recipes {
2084 known.add(refs::RefKind::Recipe, r.id.clone());
2085 }
2086 let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
2088 for (files, feature) in ores {
2089 out.outputs.extend(files);
2090 defs.biome_features.push(feature);
2091 }
2092 for (file, line, path, r) in &deferred {
2094 let found = match r.kind.as_str() {
2095 "level" => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
2096 "ladder" => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
2097 "tool_material" => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
2098 .then_some(())
2099 .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
2100 "armor_material" => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
2101 .then_some(())
2102 .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
2103 "sound_group" => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
2104 .then_some(())
2105 .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
2106 kind => match refs::RefKind::from_word(kind) {
2107 Some(k) => known.check(k, &r.id),
2108 None => Ok(()),
2109 },
2110 };
2111 if let Err(e) = found {
2112 errors.push(format!("{file}:{line}: {path}: {e}"));
2113 }
2114 }
2115 for e in &entries {
2116 if let Some((level, line)) = &e.block.tool_level {
2117 tool_level_tags(&e.id, level, &defs, &mut out, &mut |m| errors.push(format!("{}:{line}: block.tool_level: {m}", e.file)), &mut |m| warnings.push(format!("{}:{line}: block.tool_level: {m}", e.file)));
2118 }
2119 }
2120 let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
2121 let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
2123 for (shown, text) in &pending_books {
2124 let mut cx = Ctx {
2125 root,
2126 refs,
2127 file: shown.clone(),
2128 ns: mod_ns.unwrap_or("minecraft").to_string(),
2129 errors: &mut errors,
2130 warnings: &mut warnings,
2131 textures: &mut textures,
2132 inventories: &mut inventories,
2133 deferred: &mut deferred,
2134 };
2135 if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
2136 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2137 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2138 }
2139 books.push(b);
2140 }
2141 }
2142 if let Some(ns) = mod_ns {
2145 let screens = inventory_screens(&root.join("src"));
2146 for (file, line, id) in &inventories {
2147 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2148 errors.push(format!(
2149 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2150 ));
2151 }
2152 }
2153 }
2154 for (target, source) in &textures {
2155 match std::fs::read(source) {
2156 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2157 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2158 }
2159 }
2160 out.warnings = warnings;
2161 if !errors.is_empty() {
2162 return Err(format!(
2163 "invalid blocks/items in content/:\n {}",
2164 errors.join("\n ")
2165 ));
2166 }
2167 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2168 out.items = entries.len() - out.blocks;
2169 out.recipes = recipes.len();
2170 out.books = books.len();
2171 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !defs.sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !dimensions.is_empty())
2172 && !mentions_content_macro(&root.join("src"))
2173 {
2174 out.warnings.push(format!(
2175 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2176 CONTENT_DIR
2177 ));
2178 }
2179 out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
2180 out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
2181 for m in &defs.tool_materials {
2183 if let Some((n, p)) = m.incorrect.split_once(':')
2184 && n != "minecraft"
2185 {
2186 out.tags.entry(format!("when/any/1.21/data/{n}/tags/block/{p}.json")).or_default();
2187 }
2188 }
2189 defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
2190 defs.tables = tables;
2191 out.source = Some(rust_source(&entries, &recipes, &books, &defs));
2192 version_overlays(&mut out, &known.datapack);
2193 Ok(out)
2194}
2195
2196fn version_overlays(out: &mut Compiled, datapack: &str) {
2201 fn map(v: &mut Value, version: &str, missing: &mut BTreeSet<String>) -> bool {
2202 match v {
2203 Value::String(s) if s.starts_with("minecraft:") => {
2204 let id = s.trim_start_matches('#').to_string();
2205 let eq = refs::equivalent(refs::RefKind::Item, &id, version).or_else(|| refs::equivalent(refs::RefKind::Block, &id, version));
2206 match eq {
2207 Some(e) if e != id => {
2208 *s = e;
2209 true
2210 }
2211 Some(_) => false,
2212 None => {
2214 if refs::is_vanilla(&id) {
2215 missing.insert(id);
2216 }
2217 false
2218 }
2219 }
2220 }
2221 Value::Array(a) => a.iter_mut().fold(false, |c, x| map(x, version, missing) | c),
2222 Value::Object(o) => o.values_mut().fold(false, |c, x| map(x, version, missing) | c),
2223 _ => false,
2224 }
2225 }
2226 let mut overlays = Vec::new();
2227 for (path, bytes) in &out.outputs {
2228 if !path.starts_with("data/") || !path.ends_with(".json") {
2229 continue;
2230 }
2231 let Ok(json) = serde_json::from_slice::<Value>(bytes) else { continue };
2232 for version in refs::versions().iter().filter(|v| *v != datapack) {
2233 let mut copy = json.clone();
2234 let mut missing = BTreeSet::new();
2235 if map(&mut copy, version, &mut missing) {
2236 overlays.push((format!("when/any/{version}/{path}"), pretty(©)));
2237 }
2238 for id in missing {
2239 out.warnings.push(format!("{path} names {id}, which Minecraft {version} hasn't got"));
2240 }
2241 }
2242 }
2243 out.outputs.extend(overlays);
2244}
2245
2246
2247fn mentions_content_macro(dir: &Path) -> bool {
2249 let Ok(entries) = std::fs::read_dir(dir) else {
2250 return false;
2251 };
2252 entries.flatten().any(|e| {
2253 let p = e.path();
2254 if p.is_dir() {
2255 mentions_content_macro(&p)
2256 } else {
2257 p.extension().is_some_and(|x| x == "rs")
2258 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
2259 }
2260 })
2261}
2262
2263fn rust_ident(path: &str) -> String {
2265 let mut s: String = path
2266 .chars()
2267 .map(|c| {
2268 if c.is_ascii_alphanumeric() {
2269 c.to_ascii_lowercase()
2270 } else {
2271 '_'
2272 }
2273 })
2274 .collect();
2275 if s.starts_with(|c: char| c.is_ascii_digit()) {
2276 s.insert(0, '_');
2277 }
2278 s
2279}
2280
2281fn float(n: f64) -> String {
2282 let s = format!("{n:?}");
2283 if s.contains('.') || s.contains('e') {
2284 s
2285 } else {
2286 format!("{s}.0")
2287 }
2288}
2289
2290fn when_loaded(requires: &[String], stmt: &str) -> String {
2292 if requires.is_empty() {
2293 return stmt.to_string();
2294 }
2295 let cond: Vec<String> = requires.iter().map(|m| format!("::yog_api::mod_loaded({m:?})")).collect();
2296 format!("if {} {{ {stmt} }}", cond.join(" && "))
2297}
2298
2299fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile], defs: &Defs) -> String {
2300 let sound_groups = &defs.sound_groups;
2301 let mut code = String::from(
2302 "// <auto-generated>\n// Generated by `yog build` from content/**/*.yblock and *.yitem — don't edit,\n// it's rewritten on every build. Included by `yog_api::content!()`.\n// </auto-generated>\n",
2303 );
2304 for (kind, set, def) in [
2305 (Kind::Block, "Blocks", "BlockDef"),
2306 (Kind::Item, "Items", "ItemDef"),
2307 ] {
2308 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
2309 let _ = writeln!(
2310 code,
2311 "\n/// The {} of `content/`: the id and a [`::yog_api::{def}`] of each.\n#[allow(dead_code)]\npub struct {set};\n\n#[allow(dead_code, clippy::all)]\nimpl {set} {{",
2312 if kind == Kind::Block {
2313 "blocks"
2314 } else {
2315 "items"
2316 }
2317 );
2318 let mut used: HashMap<String, usize> = HashMap::new();
2319 for e in mine {
2320 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2321 let mut ident = rust_ident(path);
2322 let n = used.entry(ident.clone()).or_insert(0);
2323 *n += 1;
2324 if *n > 1 {
2325 let _ = write!(ident, "_{n}");
2326 }
2327 let upper = ident.to_ascii_uppercase();
2328 let ident = if is_keyword(&ident) {
2329 format!("r#{ident}")
2330 } else {
2331 ident
2332 };
2333 let _ = writeln!(
2334 code,
2335 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
2336 e.id, e.file, e.id
2337 );
2338 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
2339 match kind {
2340 Kind::Recipe | Kind::Book | Kind::Def => unreachable!("only blocks and items get here"),
2341 Kind::Block => {
2342 let p = &e.block;
2343 if p.hardness.is_some() || p.resistance.is_some() {
2344 let _ = write!(
2345 b,
2346 "\n .strength({}, {})",
2347 float(p.hardness.unwrap_or(1.5)),
2348 float(p.resistance.unwrap_or(6.0))
2349 );
2350 }
2351 if let Some(s) = &p.sound {
2352 let _ = write!(b, "\n .sound({s:?})");
2353 }
2354 if p.light > 0 {
2355 let _ = write!(b, "\n .light_level({})", p.light);
2356 }
2357 if p.requires_tool {
2358 b.push_str("\n .requires_tool()");
2359 }
2360 if p.no_collision {
2361 b.push_str("\n .no_collision()");
2362 }
2363 if let Some(s) = p.slipperiness {
2364 let _ = write!(b, "\n .slipperiness({})", float(s));
2365 }
2366 if let Some(s) = p.shape {
2367 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
2368 let _ = write!(b, "\n .shape({})", args.join(", "));
2369 }
2370 if p.connects {
2371 b.push_str("\n .connects_to_neighbors()");
2372 }
2373 if !p.connect_groups.is_empty() {
2374 let groups: Vec<String> =
2375 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
2376 let _ =
2377 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
2378 }
2379 if let Some(inv) = &p.inventory {
2380 let _ = write!(b, "\n .inventory({inv:?})");
2381 }
2382 if p.redstone {
2383 b.push_str("\n .redstone_source()");
2384 }
2385 if p.facing {
2386 b.push_str("\n .facing()");
2387 }
2388 }
2389 Kind::Item => {
2390 let p = &e.item;
2391 if let Some(s) = p.stack_size {
2392 let _ = write!(b, "\n .max_stack({s})");
2393 }
2394 if p.durability > 0 {
2395 let _ = write!(b, "\n .max_damage({})", p.durability);
2396 }
2397 if p.fire_resistant {
2398 b.push_str("\n .fire_resistant()");
2399 }
2400 if p.fuel_ticks > 0 {
2401 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
2402 }
2403 if let Some((kind, material, d, s)) = &p.tool {
2404 let _ = write!(b, "\n .tool({kind:?}, {material:?}, {d:?}, {s:?})");
2405 }
2406 if let Some((slot, material)) = &p.armor {
2407 let _ = write!(b, "\n .armor({slot:?}, {material:?})");
2408 }
2409 if let Some((n, s, always)) = p.food {
2410 let _ = write!(
2411 b,
2412 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
2413 float(s),
2414 if always { ".can_always_eat()" } else { "" }
2415 );
2416 }
2417 }
2418 }
2419 let _ = writeln!(
2420 code,
2421 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
2422 e.file
2423 );
2424 }
2425 code.push_str("}\n");
2426 }
2427
2428 code.push_str("\n/// Registers every block and item of `content/` whose file doesn't say\n/// `\"register\": \"manual\"` — `yog_api::content!()` calls it after\n/// `Mod::register`.\n#[allow(unused_variables)]\npub fn register(registry: &mut ::yog_api::Registry) {\n");
2429 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
2430 for e in entries {
2431 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2432 let mut ident = rust_ident(path);
2433 let n = used.entry((e.kind, ident.clone())).or_insert(0);
2434 *n += 1;
2435 if *n > 1 {
2436 let _ = write!(ident, "_{n}");
2437 }
2438 let ident = if is_keyword(&ident) {
2439 format!("r#{ident}")
2440 } else {
2441 ident
2442 };
2443 if !e.auto {
2444 continue;
2445 }
2446 let _ = match e.kind {
2447 Kind::Block => writeln!(code, " {}", when_loaded(&e.requires, &format!("registry.register_block(Blocks::{ident}());"))),
2448 Kind::Item => writeln!(code, " {}", when_loaded(&e.requires, &format!("registry.register_item(Items::{ident}());"))),
2449 Kind::Recipe | Kind::Book | Kind::Def => unreachable!("only blocks and items get here"),
2450 };
2451 }
2452 for g in sound_groups {
2453 let _ = writeln!(
2454 code,
2455 " registry.register_sound_group(::yog_api::SoundGroup::new({:?}).sounds({:?}, {:?}, {:?}, {:?}, {:?}).volume({}).pitch({}));",
2456 g.id,
2457 g.break_sound,
2458 g.step,
2459 g.place,
2460 g.hit,
2461 g.fall,
2462 float(g.volume as f64),
2463 float(g.pitch as f64)
2464 );
2465 }
2466 for id in &defs.dimensions {
2467 let _ = writeln!(
2468 code,
2469 " registry.register_dimension_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
2470 dimension_path(id)
2471 );
2472 }
2473 for (feature, biomes, step) in &defs.biome_features {
2474 let _ = writeln!(code, " registry.add_biome_feature({feature:?}, {biomes:?}, {step:?});");
2475 }
2476 for m in &defs.tool_materials {
2477 let _ = writeln!(
2478 code,
2479 " registry.register_tool_material(::yog_api::ToolMaterial {{ id: {:?}.into(), durability: {}, speed: {:?}, attack_damage: {:?}, enchantability: {}, level: {:?}.into(), repair: {:?}.into(), level_index: {}, incorrect: {:?}.into() }});",
2480 m.id, m.durability, m.speed, m.attack_damage, m.enchantability, m.level, m.repair, m.level_index, m.incorrect
2481 );
2482 }
2483 for m in &defs.armor_materials {
2484 let d = m.defense;
2485 let _ = writeln!(
2486 code,
2487 " registry.register_armor_material(::yog_api::ArmorMaterial {{ id: {:?}.into(), durability: {}, defense: ::yog_api::ArmorDefense {{ head: {}, chest: {}, legs: {}, feet: {} }}, enchantability: {}, equip_sound: {:?}.into(), toughness: {:?}, knockback_resistance: {:?}, repair: {:?}.into(), texture: {:?}.into() }});",
2488 m.id, m.durability, d.head, d.chest, d.legs, d.feet, m.enchantability, m.equip_sound, m.toughness, m.knockback_resistance, m.repair, m.texture
2489 );
2490 }
2491 for r in recipes {
2492 let _ = writeln!(code, " {}", when_loaded(&r.requires, &r.registration()));
2493 }
2494 for b in books {
2495 let _ = writeln!(
2496 code,
2497 " registry.register_book_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
2498 b.id,
2499 b.path()
2500 );
2501 }
2502 code.push_str("}\n");
2503 for t in &defs.tables {
2504 code.push_str(&table::rust(t));
2505 }
2506 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !defs.dimensions.is_empty() {
2507 code.push_str(
2508 "\n// Runs when the mod's library loads: `export_mod!` calls `register` after\n// `Mod::register`.\n#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]\nfn __yog_content_auto_register() {\n ::yog_api::__yog_content_registrars()\n .lock()\n .unwrap_or_else(|e| e.into_inner())\n .push(register);\n}\n",
2509 );
2510 }
2511 code
2512}
2513
2514fn is_keyword(s: &str) -> bool {
2515 const KW: &[&str] = &[
2516 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2517 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2518 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2519 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2520 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2521 ];
2522 KW.contains(&s)
2523}
2524
2525pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2528 let out = root.join(GENERATED_FILE);
2529 let Some(code) = &compiled.source else {
2530 let _ = std::fs::remove_file(&out);
2531 return Ok(());
2532 };
2533 crate::write_if_changed(&out, code)?;
2534 let dir = out.parent().unwrap_or(root);
2535 for (path, bytes) in &compiled.data_files {
2536 let file = dir.join(path);
2537 if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
2538 if let Some(parent) = file.parent() {
2539 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
2540 }
2541 std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
2542 }
2543 }
2544 Ok(())
2545}
2546
2547#[derive(Default)]
2550pub struct OutputSet {
2551 items: Vec<Option<Output>>,
2552 at: HashMap<String, usize>,
2553}
2554
2555impl OutputSet {
2556 pub fn get(&self, path: &str) -> Option<&[u8]> {
2557 let i = *self.at.get(path)?;
2558 self.items[i].as_ref().map(|(_, b)| b.as_slice())
2559 }
2560
2561 pub fn contains(&self, path: &str) -> bool {
2562 self.at.contains_key(path)
2563 }
2564
2565 pub fn paths(&self) -> impl Iterator<Item = &String> {
2566 self.at.keys()
2567 }
2568
2569 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2571 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2572 self.items[i] = None;
2573 }
2574 self.items.push(Some((path, bytes)));
2575 }
2576
2577 pub fn into_vec(self) -> Vec<Output> {
2578 self.items.into_iter().flatten().collect()
2579 }
2580}
2581
2582impl From<Vec<Output>> for OutputSet {
2583 fn from(outputs: Vec<Output>) -> Self {
2584 let mut set = OutputSet::default();
2585 for (path, bytes) in outputs {
2586 set.put(path, bytes);
2587 }
2588 set
2589 }
2590}
2591
2592fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2595 let read;
2596 let bytes = match outputs.get(path) {
2597 Some(b) => b,
2598 None => match std::fs::read(root.join(path)) {
2599 Ok(b) => {
2600 read = b;
2601 &read
2602 }
2603 Err(_) => return Ok(Map::new()),
2604 },
2605 };
2606 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2607}
2608
2609pub fn merge_outputs(
2613 root: &Path,
2614 outputs: &mut Vec<Output>,
2615 compiled: Compiled,
2616) -> Result<(), String> {
2617 let mut set = OutputSet::from(std::mem::take(outputs));
2618 let mut errors = Vec::new();
2619 if !compiled.lang_aliases.is_empty() {
2622 let mut lang_files: BTreeSet<String> =
2623 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2624 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2625 for ns in dirs.flatten() {
2626 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2627 for f in files.flatten() {
2628 if let Ok(rel) = f.path().strip_prefix(root) {
2629 let rel = rel.to_string_lossy().replace('\\', "/");
2630 if is_lang_path(&rel) {
2631 lang_files.insert(rel);
2632 }
2633 }
2634 }
2635 }
2636 }
2637 }
2638 for path in lang_files {
2639 let mut map = match json_output(&set, root, &path) {
2640 Ok(m) => m,
2641 Err(e) => {
2642 errors.push(e);
2643 continue;
2644 }
2645 };
2646 let mut changed = false;
2647 for (from, to) in &compiled.lang_aliases {
2648 if let Some(text) = map.get(from).cloned()
2649 && !map.contains_key(to)
2650 {
2651 map.insert(to.clone(), text);
2652 changed = true;
2653 }
2654 }
2655 if changed {
2656 set.put(path, pretty(&Value::Object(map)));
2657 }
2658 }
2659 }
2660 for ((ns, locale), entries) in &compiled.lang {
2661 let path = format!("assets/{ns}/lang/{locale}.json");
2662 let mut map = match json_output(&set, root, &path) {
2663 Ok(m) => m,
2664 Err(e) => {
2665 errors.push(e);
2666 continue;
2667 }
2668 };
2669 for (k, v) in entries {
2670 map.entry(k.clone())
2671 .or_insert_with(|| Value::String(v.clone()));
2672 }
2673 set.put(path, pretty(&Value::Object(map)));
2674 }
2675 for (path, ids) in &compiled.tags {
2676 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2679 let path = if !set.contains(path) && root.join(&plural).is_file() {
2680 plural
2681 } else {
2682 path.clone()
2683 };
2684 let mut map = match json_output(&set, root, &path) {
2685 Ok(m) => m,
2686 Err(e) => {
2687 errors.push(e);
2688 continue;
2689 }
2690 };
2691 let values = map
2692 .entry("values")
2693 .or_insert_with(|| Value::Array(Vec::new()));
2694 if let Value::Array(list) = values {
2695 for id in ids {
2696 if !list.iter().any(|v| v.as_str() == Some(id)) {
2697 list.push(Value::String(id.clone()));
2698 }
2699 }
2700 }
2701 map.entry("replace").or_insert(Value::Bool(false));
2702 set.put(path, pretty(&Value::Object(map)));
2703 }
2704 for (path, bytes) in compiled.outputs {
2705 set.put(path, bytes);
2706 }
2707 *outputs = set.into_vec();
2708 if errors.is_empty() {
2709 Ok(())
2710 } else {
2711 Err(format!(
2712 "merging blocks/items into the mod's files:\n {}",
2713 errors.join("\n ")
2714 ))
2715 }
2716}
2717
2718fn is_lang_path(p: &str) -> bool {
2719 let parts: Vec<&str> = p.split('/').collect();
2720 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2721}
2722
2723#[cfg(test)]
2724mod tests {
2725 use super::*;
2726
2727 fn project(files: &[(&str, &str)]) -> PathBuf {
2728 use std::sync::atomic::{AtomicUsize, Ordering};
2729 static N: AtomicUsize = AtomicUsize::new(0);
2730 let dir = std::env::temp_dir().join(format!(
2731 "yog-content-test-{}-{}",
2732 std::process::id(),
2733 N.fetch_add(1, Ordering::SeqCst)
2734 ));
2735 let _ = std::fs::remove_dir_all(&dir);
2736 for (path, text) in files {
2737 let p = dir.join(path);
2738 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2739 std::fs::write(p, text).unwrap();
2740 }
2741 dir
2742 }
2743
2744 fn output(c: &Compiled, path: &str) -> Value {
2745 let (_, b) = c
2746 .outputs
2747 .iter()
2748 .find(|(p, _)| p == path)
2749 .unwrap_or_else(|| {
2750 panic!(
2751 "no {path} in {:?}",
2752 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2753 )
2754 });
2755 serde_json::from_slice(b).unwrap()
2756 }
2757
2758 #[test]
2759 fn compiles_a_cube_block() {
2760 let root = project(&[
2761 ("resources/block/ruby_ore.png", "png"),
2762 ("content/ruby.yitem", r#"{ model: { parent: "generated", textures: { layer0: "minecraft:item/emerald" } } }"#),
2763 (
2764 "content/blocks/ruby_ore.yblock",
2765 r#"{
2766 "name": "Ruby Ore",
2767 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2768 "drops": { "item": "@item:mymod:ruby", "count": [1, 3] } },
2769 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2770}"#,
2771 ),
2772 ]);
2773 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2774 assert_eq!(c.blocks, 1);
2775 assert_eq!(
2776 output(&c, "assets/mymod/models/block/ruby_ore.json"),
2777 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2778 );
2779 assert_eq!(
2780 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2781 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2782 );
2783 assert_eq!(
2784 output(&c, "assets/mymod/models/item/ruby_ore.json"),
2785 json!({ "parent": "mymod:block/ruby_ore" })
2786 );
2787 assert_eq!(
2788 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2789 json!("mymod:item/ruby_ore")
2790 );
2791 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2792 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2793 assert_eq!(
2794 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2795 json!(3)
2796 );
2797 assert!(
2798 c.outputs
2799 .iter()
2800 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2801 );
2802 let src = c.source.as_ref().unwrap();
2803 assert!(
2804 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2805 "{src}"
2806 );
2807 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2808 assert!(src.contains(".requires_tool()"), "{src}");
2809 assert!(
2810 src.contains("registry.register_block(Blocks::ruby_ore());"),
2811 "{src}"
2812 );
2813
2814 let mut outputs = Vec::new();
2815 merge_outputs(&root, &mut outputs, c).unwrap();
2816 let find = |p: &str| -> Value {
2817 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2818 };
2819 assert_eq!(
2820 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2821 json!("Ruby Ore")
2822 );
2823 assert_eq!(
2824 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2825 json!(["mymod:ruby_ore"])
2826 );
2827 assert_eq!(
2828 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2829 json!(["mymod:ruby_ore"])
2830 );
2831 let _ = std::fs::remove_dir_all(root);
2832 }
2833
2834 #[test]
2835 fn raw_parent_with_elements_gets_a_particle() {
2836 let root = project(&[(
2837 "content/cable.yblock",
2838 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2839 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2840 )]);
2841 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2842 let m = output(&c, "assets/mymod/models/block/cable.json");
2843 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2844 let _ = std::fs::remove_dir_all(root);
2845 }
2846
2847 #[test]
2848 fn a_facing_block_turns_to_its_placer() {
2849 let root = project(&[(
2850 "content/screen.yblock",
2851 r##"{ "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } },
2852 "states": { "facing": "horizontal" } }"##,
2853 )]);
2854 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2855 let bs = output(&c, "assets/mymod/blockstates/screen.json");
2856 assert_eq!(bs["variants"]["facing=north"], json!({ "model": "mymod:block/screen" }));
2857 assert_eq!(bs["variants"]["facing=west"]["y"], json!(270));
2858 assert!(c.source.unwrap().contains(".facing()"));
2859 let _ = std::fs::remove_dir_all(root);
2860 }
2861
2862 #[test]
2863 fn compiles_elements_shape_and_connect() {
2864 let root = project(&[(
2865 "content/pipe.yblock",
2866 r##"{
2867 "block": { "render_layer": "cutout" },
2868 "model": {
2869 "textures": { "0": "minecraft:block/stone" },
2870 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2871 "rotation": { "axis": "y", "angle": 22.5 },
2872 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2873 },
2874 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2875 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2876}"##,
2877 )]);
2878 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2879 let m = output(&c, "assets/mymod/models/block/pipe.json");
2880 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2881 assert_eq!(m["render_type"], json!("minecraft:cutout"));
2882 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2883 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2884 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2885 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2886 let src = c.source.unwrap();
2887 assert!(
2888 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2889 "{src}"
2890 );
2891 assert!(src.contains(".connects_to_neighbors()"), "{src}");
2892 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2893 let _ = std::fs::remove_dir_all(root);
2894 }
2895
2896 #[test]
2897 fn sound_groups_are_the_games_or_a_mods_ydef() {
2898 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
2899 let crystal = r#"{ kind: "sound_group", id: "crystal", break: "crystal.break", step: "minecraft:block.glass.step",
2900 place: "crystal.place", hit: "crystal.hit", fall: "crystal.fall", pitch: 1.5 }"#;
2901 let a = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:crystal" }} }}"#);
2902 let b = format!(r#"{{ {cube}, block: {{ sound: "amethyst" }} }}"#);
2903 let c = compile(&project(&[("content/a.yblock", &a), ("content/b.yblock", &b), ("content/crystal.ydef", crystal)]), "mymod", &yres::Refs::default()).unwrap();
2904 let src = c.source.unwrap();
2905 assert!(src.contains(r#"registry.register_sound_group(::yog_api::SoundGroup::new("mymod:crystal").sounds("mymod:crystal.break", "minecraft:block.glass.step", "mymod:crystal.place", "mymod:crystal.hit", "mymod:crystal.fall").volume(1.0).pitch(1.5));"#), "{src}");
2906 assert!(src.contains(r#".sound("mymod:crystal")"#) && src.contains(r#".sound("minecraft:amethyst")"#), "{src}");
2907
2908 let bad = format!(r#"{{ {cube}, block: {{ sound: "crystal" }} }}"#);
2909 let missing = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:nope" }} }}"#);
2910 let e = compile(&project(&[("content/a.yblock", &bad), ("content/b.yblock", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
2911 assert!(e.contains("`crystal`: not one of the game's sound groups"), "{e}");
2912 assert!(e.contains("no sound group mymod:nope"), "{e}");
2913 }
2914
2915 #[test]
2916 fn tools_are_tags_and_levels_are_definitions_a_mod_can_add() {
2917 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
2918 let mythril = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
2919 let block = format!(r#"{{ {cube}, block: {{ tool: ["pickaxe", "@tag:block:mymod:mineable/hammer"], tool_level: "@level:mymod:mythril" }} }}"#);
2920 let c = compile(&project(&[("content/ore.yblock", &block), ("content/mythril.ydef", mythril)]), "mymod", &yres::Refs::default()).unwrap();
2921 for tag in [
2922 "data/minecraft/tags/block/mineable/pickaxe.json",
2923 "data/mymod/tags/block/mineable/hammer.json",
2924 "data/minecraft/tags/block/needs_diamond_tool.json",
2926 "when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json",
2927 "when/fabric/1.20/data/fabric/tags/block/needs_tool_level_4.json",
2928 "when/forge/1.20/data/forge/tags/block/needs_netherite_tool.json",
2929 ] {
2930 assert!(c.tags.get(tag).is_some_and(|ids| ids.contains("mymod:ore")), "{tag}: {:?}", c.tags);
2931 }
2932 let iron = format!(r#"{{ {cube}, block: {{ tool: "pickaxe", tool_level: "iron" }} }}"#);
2934 let c = compile(&project(&[("content/ore.yblock", &iron)]), "mymod", &yres::Refs::default()).unwrap();
2935 assert!(c.tags["data/minecraft/tags/block/needs_iron_tool.json"].contains("mymod:ore"));
2936 assert_eq!(c.tags.len(), 2);
2937 let bad = format!(r#"{{ {cube}, block: {{ tool: "hammer", tool_level: "mythril" }} }}"#);
2939 let e = compile(&project(&[("content/ore.yblock", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2940 assert!(e.contains("block.tool: `hammer`: one of pickaxe, axe, shovel, hoe — or a tag of the mod's own tools"), "{e}");
2941 assert!(e.contains("block.tool_level: `mythril`: one of wood, stone, iron, diamond, netherite — or a level of the tool ladder"), "{e}");
2942 let volts = r#"{ kind: "ladder", id: "voltage" }"#;
2943 let lv = r#"{ kind: "level", id: "lv", ladder: "@ladder:mymod:voltage" }"#;
2944 let loop_ = r#"{ kind: "level", id: "hv", ladder: "@ladder:mymod:voltage", above: "@level:mymod:nope" }"#;
2945 let wrong = format!(r#"{{ {cube}, block: {{ tool_level: "@level:mymod:lv", drops: {{ item: "@item:mymod:nope" }} }} }}"#);
2946 let e = compile(&project(&[("content/v.ydef", volts), ("content/lv.ydef", lv), ("content/hv.ydef", loop_), ("content/ore.yblock", &wrong)]), "mymod", &yres::Refs::default()).unwrap_err();
2947 assert!(e.contains("level mymod:hv: above mymod:nope, which isn't a level of mymod:voltage"), "{e}");
2948 assert!(e.contains("block.tool_level: mymod:lv is a level of mymod:voltage, not of the tool ladder"), "{e}");
2949 assert!(e.contains("block.drops.item: no item mymod:nope in this mod"), "{e}");
2950 }
2951
2952 #[test]
2953 fn reports_errors_with_lines() {
2954 let root = project(&[(
2956 "content/bad.yblock",
2957 "{\n \"model\": {\n \"textures\": { \"0\": \"minecraft:block/stone\" },\n \"elements\": [ {\n \"from\": [0, 0, 0], \"to\": [16, 16, 99],\n \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n \"faces\": { \"nort\": { \"texture\": \"#1\" } }\n } ]\n },\n \"bogus\": 1\n}",
2958 )]);
2959 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2960 for want in [
2961 "content/bad.yblock:5: model.elements[0].to: 99 is out of range (-16..=32)",
2962 "content/bad.yblock:6: model.elements[0].rotation.axis: unknown `q` — did you mean `x`?",
2963 "content/bad.yblock:7: model.elements[0].faces.nort: unknown field `nort` — did you mean `north`?",
2964 "content/bad.yblock:10: bogus: unknown field `bogus`",
2965 ] {
2966 assert!(e.contains(want), "missing {want}\n{e}");
2967 }
2968 let _ = std::fs::remove_dir_all(root);
2969 let root = project(&[(
2971 "content/bad.yblock",
2972 "{\n model: {\n textures: { \"0\": \"minecraft:block/stone\" },\n elements: [ {\n from: [0, 0, 0], to: [16, 16, 16],\n rotation: { axis: \"x\", angle: 30 },\n faces: { north: { texture: \"#1\" } }\n } ]\n },\n}",
2973 )]);
2974 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2975 for want in [
2976 "content/bad.yblock:6: model.elements[0].rotation.angle: 30 — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees",
2977 "content/bad.yblock:7: model.elements[0].faces.north.texture: `#1`: no such slot in textures",
2978 ] {
2979 assert!(e.contains(want), "missing {want}\n{e}");
2980 }
2981 let _ = std::fs::remove_dir_all(root);
2982 }
2983
2984 #[test]
2985 fn compiles_recipes() {
2986 let root = project(&[
2987 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2988 (
2989 "content/recipes/ruby_block.yrecipe",
2990 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
2991 ),
2992 (
2993 "content/recipes/ruby.yrecipe",
2994 r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
2995 ),
2996 (
2997 "content/recipes/smelt_ruby.yrecipe",
2998 r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
2999 ),
3000 (
3001 "content/recipes/cut.yrecipe",
3002 r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
3003 ),
3004 ]);
3005 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3006 assert_eq!(c.recipes, 4);
3007 let src = c.source.clone().unwrap();
3008 for want in [
3009 r#"kind: ::yog_api::RecipeKind::Shaped { pattern: vec!["RR".into(), "RR".into()], key: vec![('R', vec!["mymod:ruby".into()])] }, result: "minecraft:emerald_block".into(), count: 1"#,
3010 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
3011 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
3012 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
3013 r##"group: Some("rubies".into())"##,
3014 ] {
3015 assert!(src.contains(want), "missing {want}\n{src}");
3016 }
3017 let _ = std::fs::remove_dir_all(root);
3018 }
3019
3020 #[test]
3021 fn recipe_mistakes() {
3022 let bad = |recipe: &str| {
3023 let root = project(&[("content/r.yrecipe", recipe)]);
3024 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3025 let _ = std::fs::remove_dir_all(root);
3026 e
3027 };
3028 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
3029 assert!(e.contains("pattern uses 'B'"), "{e}");
3030 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
3031 assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
3032 let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
3033 assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
3034 let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
3035 assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
3036 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
3037 assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
3038 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
3039 assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
3040 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
3041 assert!(e.contains("unknown field `pattern`"), "{e}");
3042 let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
3043 assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
3044 }
3045
3046 #[test]
3047 fn block_inventory_names_a_screen() {
3048 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
3049 let root = project(&[
3050 ("content/bench.yblock", block),
3051 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
3052 ]);
3053 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3054 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
3055 let _ = std::fs::remove_dir_all(root);
3056
3057 let root = project(&[("content/bench.yblock", block)]);
3058 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3059 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
3060 let _ = std::fs::remove_dir_all(root);
3061 }
3062
3063 #[test]
3064 fn compiles_a_book() {
3065 let book = r#"{
3066 "id": "guide",
3067 "name": "@Book.title",
3068 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
3069 "entries": [{
3070 "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
3071 "pages": [
3072 { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
3073 { "type": "spotlight", "item": "@item:mymod:ruby" },
3074 { "type": "entity", "entity": "@entity:minecraft:allay" }
3075 ]
3076 }]
3077 }"#;
3078 let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
3079 let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
3080 let mut refs = yres::Refs::default();
3081 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
3082 let out = compile(&root, "mymod", &refs).unwrap();
3083 assert_eq!(out.books, 1);
3084 let src = out.source.unwrap();
3085 assert!(src.contains(r#"registry.register_book_data("mymod:guide", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/books/mymod__guide.ydata")));"#), "{src}");
3086 assert_eq!(out.data_files[0].0, "books/mymod__guide.ydata");
3088 let data = yog_data::binary::decode(&out.data_files[0].1).unwrap();
3089 let text = yog_data::text::to_string(&data);
3090 assert!(text.contains("mymod.book.title") && text.contains("item.mymod.ruby"), "{text}");
3091 assert!(text.contains("not a ref") && text.contains("i18n"), "{text}");
3092 let read = yog_data::from_value::<yog_book::Book>(&data).unwrap().value;
3093 assert_eq!(read.entries[0].category, "mymod:basics");
3094 assert!(matches!(&read.entries[0].pages[1], yog_book::BookPage::Spotlight { item, .. } if item == "mymod:ruby"), "references become ids");
3095
3096 let bad = r#"{ "id": "x",
3098 "categories": [{ "id": "c", "name": "C" }],
3099 "entries": [{ "id": "e", "name": "E", "category": "c",
3100 "pages": [
3101 { "type": "spotlight", "item": "minecraft:stick" },
3102 { "type": "spotlight", "item": "@item:minecraft:grass" },
3103 { "type": "recipe", "recipe": "@recipe:mymod:nope" },
3104 { "type": "entity", "entity": "@entity:create:train" }
3105 ] }] }"#;
3106 let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3107 assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
3108 assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
3109 assert!(e.contains("minecraft:grass isn't in Minecraft 1.21.1 (the mod's datapack; it's in 1.20.1) — there it's minecraft:short_grass"), "{e}");
3110 assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
3111 assert!(e.contains("[external.create]"), "{e}");
3112
3113 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
3114 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
3115 assert!(e.contains("entries[0].pages[0].type: unknown page type `nope`"), "{e}");
3116 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
3117 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
3118 assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
3119 }
3120
3121 #[test]
3122 fn books_use_the_mods_own_page_types() {
3123 let ty = r#"<Component name="PatternPage" book_page="pattern">
3124 <Property name="name" type="book_text"/>
3125 <Property name="patterns" type="data" default="[]"/>
3126 <BookHeader text="{x:Bind name}"/>
3127 <Drawn drawer="mymod:pattern" h="64" data="{x:Bind patterns}"/>
3128 </Component>"#;
3129 let book = |page: &str| format!(r#"{{ id: "guide", name: "G", categories: [{{ id: "mymod:c", name: "C" }}],
3130 entries: [{{ id: "mymod:e", name: "E", category: "@category:mymod:c", pages: [{page}] }}] }}"#);
3131 let ok = book(r#"{ type: "mymod:pattern", name: "@item:minecraft:stick", patterns: [{ startdir: "EAST", signature: "qaq" }] }"#);
3132 let c = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &ok)]), "mymod", &yres::Refs::default()).unwrap();
3133 let b = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
3134 assert_eq!(b.page_types.len(), 1, "the type goes with the book");
3135 let yog_book::BookPage::Custom(p) = &b.entries[0].pages[0] else { panic!("{:?}", b.entries[0].pages[0]) };
3136 assert_eq!(p.fields.get("name").and_then(|n| n.as_str()), Some("item.minecraft.stick"), "texts resolve as the book's do");
3137 assert!(b.i18n);
3138
3139 let unknown = book(r#"{ type: "mymod:pattern", name: "N", colour: 3 }"#);
3140 let e = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &unknown)]), "mymod", &yres::Refs::default()).unwrap_err();
3141 assert!(e.contains("mymod:pattern has no field `colour` (name, patterns)"), "{e}");
3142 let missing = book(r#"{ type: "mymod:nope" }"#);
3143 let e = compile(&project(&[("content/guide.ybook", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3144 assert!(e.contains("no page type mymod:nope"), "{e}");
3145 }
3146
3147 #[test]
3148 fn tools_and_armor_of_the_games_materials_or_the_mods() {
3149 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/iron_pickaxe" } }"#;
3150 let level = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3151 let tool = r#"{ kind: "tool_material", id: "mythril", durability: 1800, speed: 10, attack_damage: 3, enchantability: 18,
3152 level: "@level:mymod:mythril", repair: "@tag:item:c:ingots" }"#;
3153 let armor = r#"{ kind: "armor_material", id: "mythril", durability: 30, defense: { head: 3, chest: 8, legs: 6, feet: 3 },
3154 enchantability: 18, equip_sound: "minecraft:item.armor.equip_diamond", repair: "@item:minecraft:diamond" }"#;
3155 let pick = format!(r#"{{ {model}, item: {{ tool: {{ kind: "pickaxe", material: "@tool_material:mymod:mythril" }} }} }}"#);
3156 let helm = format!(r#"{{ {model}, item: {{ armor: {{ slot: "head", material: "iron" }} }} }}"#);
3157 let files = [("content/mythril.ydef", level), ("content/mythril_tools.ydef", tool), ("content/mythril_armor.ydef", armor), ("content/pick.yitem", pick.as_str()), ("content/helm.yitem", helm.as_str())];
3158 let c = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap();
3159 let src = c.source.unwrap();
3160 assert!(src.contains(r#".tool("pickaxe", "mymod:mythril", 1.0, -2.8)"#), "the kind's attack: {src}");
3161 assert!(src.contains(r#".armor("head", "minecraft:iron")"#), "{src}");
3162 assert!(src.contains(r##"level: "mymod:mythril".into(), repair: "#c:ingots".into(), level_index: 4, incorrect: "minecraft:incorrect_for_netherite_tool".into()"##), "above diamond: netherite's on both versions: {src}");
3163 assert!(src.contains(r#"registry.register_armor_material(::yog_api::ArmorMaterial { id: "mymod:mythril".into()"#) && src.contains(r#"texture: "mymod:mythril".into()"#), "{src}");
3164
3165 let bad = format!(r#"{{ {model}, item: {{ tool: {{ kind: "spoon", material: "bronze" }} }} }}"#);
3166 let e = compile(&project(&[("content/bad.yitem", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3167 assert!(e.contains("`spoon`: one of pickaxe, axe, shovel, hoe, sword") && e.contains("`bronze`: one of the game's (wood, stone, iron, diamond, gold, netherite)"), "{e}");
3168 let missing = format!(r#"{{ {model}, item: {{ armor: {{ slot: "feet", material: "@armor_material:mymod:nope" }} }} }}"#);
3169 let e = compile(&project(&[("content/m.yitem", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3170 assert!(e.contains("no armor material mymod:nope"), "{e}");
3171 }
3172
3173 #[test]
3174 fn data_naming_renamed_content_gets_each_versions_name() {
3175 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3176 let block = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:short_grass" }} }} }}"#);
3177 let c = compile(&project(&[("content/tuft.yblock", &block)]), "mymod", &yres::Refs::default()).unwrap();
3178 let loot = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| String::from_utf8_lossy(b).to_string());
3179 assert!(loot("data/mymod/loot_table/blocks/tuft.json").is_some_and(|l| l.contains("minecraft:short_grass")), "the datapack's name");
3180 let old = loot("when/any/1.20.1/data/mymod/loot_table/blocks/tuft.json").expect("1.20.1's overlay");
3181 assert!(old.contains("minecraft:grass") && !old.contains("short_grass"), "{old}");
3182 let crafter = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:crafter" }} }} }}"#);
3183 let c = compile(&project(&[("content/c.yblock", &crafter)]), "mymod", &yres::Refs::default()).unwrap();
3184 assert!(c.warnings.iter().any(|w| w.contains("minecraft:crafter, which Minecraft 1.20.1 hasn't got")), "{:?}", c.warnings);
3185 }
3186
3187 #[test]
3188 fn ores_become_features_in_biomes_on_every_loader() {
3189 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3190 let block = format!(r#"{{ {model} }}"#);
3191 let ore = r#"{ kind: "ore", id: "mythril_ore", targets: [{ replaces: "stone", block: "@block:mymod:mythril_ore" },
3192 { replaces: "@block:minecraft:basalt", block: "@block:mymod:mythril_ore" }], size: 6, per_chunk: 4,
3193 height: { min: -48, max: 16, shape: "triangle" } }"#;
3194 let c = compile(&project(&[("content/mythril_ore.yblock", &block), ("content/mythril_ore.ydef", ore)]), "mymod", &yres::Refs::default()).unwrap();
3195 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
3196 let cf = get("data/mymod/worldgen/configured_feature/mythril_ore.json");
3197 assert_eq!(cf["config"]["targets"][0]["target"]["tag"], "minecraft:stone_ore_replaceables");
3198 assert_eq!(cf["config"]["targets"][1]["target"]["block"], "minecraft:basalt");
3199 let pf = get("data/mymod/worldgen/placed_feature/mythril_ore.json");
3200 assert_eq!(pf["placement"][2]["height"]["type"], "minecraft:trapezoid");
3201 assert_eq!(get("when/neoforge/1.21/data/mymod/neoforge/biome_modifier/mythril_ore.json")["type"], "neoforge:add_features");
3202 assert_eq!(get("when/forge/any/data/mymod/forge/biome_modifier/mythril_ore.json")["biomes"], "#minecraft:is_overworld");
3203 let src = c.source.unwrap();
3204 assert!(src.contains(r#"registry.add_biome_feature("mymod:mythril_ore", "minecraft:is_overworld", "underground_ores");"#), "{src}");
3205 let bad = r#"{ kind: "ore", id: "x", targets: [{ replaces: "granite", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
3206 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3207 assert!(e.contains("`granite`: one of stone, deepslate, netherrack, end_stone"), "{e}");
3208 let missing = r#"{ kind: "ore", id: "x", targets: [{ replaces: "stone", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
3209 let e = compile(&project(&[("content/x.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3210 assert!(e.contains("no block mymod:nope"), "{e}");
3211 }
3212
3213 #[test]
3214 fn a_dimension_and_its_terrain_go_to_the_runtime_as_data() {
3215 let dim = r#"{ kind: "dimension", id: "flatlands", dimension_type: { has_sky_light: true }, biome: "@biome:minecraft:desert",
3216 terrain: { seed: 3, base_height: 70, noise: [{ scale: 80, amplitude: 6 }], fill: "@block:minecraft:sandstone",
3217 layers: [{ block: "@block:minecraft:sand", depth: 3 }] } }"#;
3218 let c = compile(&project(&[("content/flatlands.ydef", dim)]), "mymod", &yres::Refs::default()).unwrap();
3219 let (path, data) = c.data_files.iter().find(|(p, _)| p.starts_with("dimensions/")).expect("the dimension's data");
3220 assert_eq!(path, "dimensions/mymod__flatlands.ydata");
3221 let v = yog_data::binary::decode(data).unwrap();
3222 assert_eq!(v.get("biome"), Some(&yog_data::Value::from("minecraft:desert")), "a plain id: the hosts read it");
3223 assert_eq!(v.get("generator_type"), Some(&yog_data::Value::from("mymod:flatlands")));
3224 let t = yog_data::read_value::<yog_worldgen::Terrain>(v.get("terrain").unwrap()).unwrap();
3225 assert_eq!((t.base_height, t.fill.as_str()), (70, "minecraft:sandstone"));
3226 let src = c.source.unwrap();
3227 assert!(src.contains(r#"registry.register_dimension_data("mymod:flatlands", include_bytes!("#), "{src}");
3228 let bad = r#"{ kind: "dimension", id: "x", terrain: { fill: "@block:minecraft:nope" } }"#;
3229 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3230 assert!(e.contains("no block minecraft:nope"), "{e}");
3231 }
3232
3233 #[test]
3234 fn content_for_other_mods_is_registered_only_with_them() {
3235 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } }"#;
3236 let item = format!(r#"{{ requires: ["pehkui", "create"], {model} }}"#);
3237 let recipe = r#"{ requires: ["pehkui"], type: "shapeless", ingredients: ["@item:minecraft:stick"], result: "@item:minecraft:stick" }"#;
3238 let c = compile(&project(&[("content/shrink.yitem", &item), ("content/r.yrecipe", recipe)]), "mymod", &yres::Refs::default()).unwrap();
3239 let src = c.source.unwrap();
3240 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") && ::yog_api::mod_loaded("create") { registry.register_item(Items::shrink()); }"#), "{src}");
3241 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") { registry.add_recipe("#), "{src}");
3242 }
3243
3244 #[test]
3245 fn books_lay_out_the_mods_own_recipe_kinds() {
3246 let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
3247 let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
3248 entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
3249 pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
3250 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true } } }"#;
3251 let layout = r#"<Component name="InfusionLayout" recipe_layout="infusion" width="96" height="24">
3252 <RecipeSlot slot="core" x="4" y="4" frame="true"/>
3253 <ItemSlot item="minecraft:beacon" x="40" y="4"/>
3254 <RecipeResult x="76" y="4" frame="true"/>
3255 </Component>"#;
3256 let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book), ("content/infusion.ydef", kind)];
3257 let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
3258 assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion"), "{e}");
3259 let c = compile(&project(&[files[0], files[1], files[2], ("src/ui/infusion.yui", layout)]), "mymod", &yres::Refs::default()).unwrap();
3260 let book = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
3261 assert_eq!(book.layouts[0].kinds, ["mymod:infusion"], "the book carries the mod's layouts");
3262 let bad = r#"<Component recipe_layout="create:mixing infusion" width="1" height="1">
3264 <RecipeSlot slot="ring"/><ItemSlot item="minecraft:nope"/></Component>"#;
3265 let e = compile(&project(&[files[2], ("src/ui/bad.yui", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3266 assert!(e.contains("`create:mixing`: one of shaped"), "{e}");
3267 assert!(e.contains("mymod:infusion has no slot `ring` — core"), "{e}");
3268 assert!(e.contains("<ItemSlot item=\"minecraft:nope\">: no item minecraft:nope in Minecraft"), "{e}");
3269 }
3270
3271 #[test]
3272 fn compiles_a_recipe_of_the_mods_own_kind() {
3273 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true }, ring: { max: 2 } }, values: { time: "number" } }"#;
3274 let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
3275 values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
3276 let c = compile(&project(&[("content/infusion.ydef", kind), ("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
3277 let src = c.source.unwrap();
3278 assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
3279 assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
3280
3281 let bad = r##"{ type: "mymod:infusion", slots: { ring: ["@item:minecraft:coal", "@item:minecraft:coal", "@item:minecraft:coal"], nope: ["@item:minecraft:coal"] },
3283 values: { time: "soon", heat: 3 }, result: "@item:minecraft:emerald" }"##;
3284 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3285 for want in ["slots.ring: at most 2 ingredient(s), found 3", "slots.nope: mymod:infusion has no slot `nope` (core, ring)", "slots: mymod:infusion needs `core`", "values.time: a number", "values.heat: mymod:infusion has no value `heat` (time)"] {
3286 assert!(e.contains(want), "{want}: {e}");
3287 }
3288 let undeclared = r#"{ "type": "mymod:infusion", "slots": {}, "result": "@item:minecraft:emerald" }"#;
3289 let e = compile(&project(&[("content/u.yrecipe", undeclared)]), "mymod", &yres::Refs::default()).unwrap_err();
3290 assert!(e.contains("type `mymod:infusion`: no such kind — declare it: content/infusion.ydef"), "{e}");
3291 let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
3292 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3293 assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
3294 let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
3296 let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
3297 assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
3298 }
3299
3300 #[test]
3301 fn compiles_an_item_with_resource_refs() {
3302 let root = project(&[(
3303 "content/items/ruby.yitem",
3304 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
3305 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
3306 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
3307 )]);
3308 let mut refs = yres::Refs::default();
3309 refs.texts.insert(
3310 "Items.ruby".into(),
3311 ("item.mymod.ruby".into(), "Ruby".into()),
3312 );
3313 refs.texts.insert(
3314 "Strings.ruby_tip".into(),
3315 ("mymod.ruby_tip".into(), "Shiny".into()),
3316 );
3317 refs.images
3318 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
3319 let c = compile(&root, "mymod", &refs).unwrap();
3320 assert_eq!(
3321 output(&c, "assets/mymod/models/item/ruby.json"),
3322 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
3323 );
3324 let src = c.source.clone().unwrap();
3325 assert!(src.contains(".max_stack(16)"), "{src}");
3326 assert!(
3327 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
3328 "{src}"
3329 );
3330 let mut outputs = vec![(
3332 "assets/mymod/lang/ru_ru.json".to_string(),
3333 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
3334 .as_bytes()
3335 .to_vec(),
3336 )];
3337 merge_outputs(&root, &mut outputs, c).unwrap();
3338 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
3339 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
3340 let _ = std::fs::remove_dir_all(root);
3341 }
3342
3343 #[test]
3344 fn namespace_needed_for_odd_mod_ids() {
3345 let root = project(&[(
3346 "content/x.yitem",
3347 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
3348 )]);
3349 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
3350 assert!(e.contains("give the namespace"), "{e}");
3351 let _ = std::fs::remove_dir_all(root);
3352 }
3353}