1mod import;
23pub mod json;
24mod refs;
25
26pub use import::import_command;
27
28use crate::yres;
29use json::{J, V, num_value};
30use serde_json::{Map, Value, json};
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32use std::fmt::Write as _;
33use std::path::{Path, PathBuf};
34
35pub const CONTENT_DIR: &str = "content";
37pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
39
40pub type Output = (String, Vec<u8>);
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum Kind {
46 Block,
48 Item,
50 Recipe,
52 Book,
54}
55
56impl Kind {
57 pub fn of(path: &Path) -> Option<Kind> {
58 match path.extension()?.to_str()? {
59 "yblock" => Some(Kind::Block),
60 "yitem" => Some(Kind::Item),
61 "yrecipe" => Some(Kind::Recipe),
62 "ybook" => Some(Kind::Book),
63 _ => None,
64 }
65 }
66
67 fn word(self) -> &'static str {
68 match self {
69 Kind::Block => "block",
70 Kind::Item => "item",
71 Kind::Recipe => "recipe",
72 Kind::Book => "book",
73 }
74 }
75}
76
77#[derive(Debug, Default)]
79pub struct Compiled {
80 pub outputs: Vec<Output>,
82 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
85 lang_aliases: Vec<(String, String)>,
87 tags: BTreeMap<String, BTreeSet<String>>,
89 pub source: Option<String>,
91 pub warnings: Vec<String>,
92 pub blocks: usize,
93 pub items: usize,
94 pub recipes: usize,
95 pub books: usize,
96}
97
98pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
101pub const DISPLAY_SLOTS: [&str; 8] = [
102 "thirdperson_righthand",
103 "thirdperson_lefthand",
104 "firstperson_righthand",
105 "firstperson_lefthand",
106 "gui",
107 "head",
108 "ground",
109 "fixed",
110];
111pub const SOUNDS: [&str; 10] = [
113 "stone",
114 "wood",
115 "grass",
116 "gravel",
117 "sand",
118 "snow",
119 "metal",
120 "glass",
121 "wool",
122 "nether_brick",
123];
124pub const TOOLS: [&str; 4] = ["pickaxe", "axe", "shovel", "hoe"];
125pub const TOOL_LEVELS: [&str; 3] = ["stone", "iron", "diamond"];
126pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
127pub const RECIPE_TYPES: [&str; 8] =
128 ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
129
130pub struct Template {
132 pub name: &'static str,
133 pub parent: &'static str,
135 pub slots: &'static [&'static str],
137 pub sprite: bool,
139 pub has_particle: bool,
141}
142
143pub const TEMPLATES: &[Template] = &[
144 Template {
145 name: "cube_all",
146 parent: "minecraft:block/cube_all",
147 slots: &["all"],
148 sprite: false,
149 has_particle: true,
150 },
151 Template {
152 name: "cube_column",
153 parent: "minecraft:block/cube_column",
154 slots: &["end", "side"],
155 sprite: false,
156 has_particle: true,
157 },
158 Template {
159 name: "cube_bottom_top",
160 parent: "minecraft:block/cube_bottom_top",
161 slots: &["top", "bottom", "side"],
162 sprite: false,
163 has_particle: true,
164 },
165 Template {
166 name: "cube",
167 parent: "minecraft:block/cube",
168 slots: &["down", "up", "north", "south", "west", "east"],
169 sprite: false,
170 has_particle: false,
171 },
172 Template {
173 name: "orientable",
174 parent: "minecraft:block/orientable",
175 slots: &["front", "side", "top"],
176 sprite: false,
177 has_particle: true,
178 },
179 Template {
180 name: "cross",
181 parent: "minecraft:block/cross",
182 slots: &["cross"],
183 sprite: false,
184 has_particle: true,
185 },
186 Template {
187 name: "generated",
188 parent: "minecraft:item/generated",
189 slots: &["layer0"],
190 sprite: true,
191 has_particle: true,
192 },
193 Template {
194 name: "handheld",
195 parent: "minecraft:item/handheld",
196 slots: &["layer0"],
197 sprite: true,
198 has_particle: true,
199 },
200];
201
202pub fn template(name: &str) -> Option<&'static Template> {
203 TEMPLATES.iter().find(|t| t.name == name)
204}
205
206pub fn valid_namespace(s: &str) -> bool {
208 !s.is_empty()
209 && s.bytes().all(|b| {
210 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
211 })
212}
213
214pub fn valid_path(s: &str) -> bool {
216 !s.is_empty()
217 && !s.starts_with('/')
218 && !s.ends_with('/')
219 && s.bytes().all(|b| {
220 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
221 })
222}
223
224pub fn valid_id(s: &str) -> bool {
226 match s.split_once(':') {
227 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
228 None => valid_path(s),
229 }
230}
231
232pub fn find(root: &Path) -> Vec<PathBuf> {
236 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
237 let Ok(entries) = std::fs::read_dir(dir) else {
238 return;
239 };
240 for entry in entries.flatten() {
241 let path = entry.path();
242 if path.is_dir() {
243 walk(&path, out);
244 } else if Kind::of(&path).is_some() {
245 out.push(path);
246 }
247 }
248 }
249 let mut out = Vec::new();
250 walk(&root.join(CONTENT_DIR), &mut out);
251 out.sort();
252 out
253}
254
255fn display(root: &Path, p: &Path) -> String {
256 p.strip_prefix(root)
257 .unwrap_or(p)
258 .to_string_lossy()
259 .replace('\\', "/")
260}
261
262#[derive(Debug, Clone)]
266enum Text {
267 Key(String),
269 Literal(String),
270}
271
272#[derive(Debug, Clone, Default)]
273struct BlockProps {
274 hardness: Option<f64>,
275 resistance: Option<f64>,
276 sound: Option<String>,
277 light: u8,
278 tool: Option<String>,
279 tool_level: Option<String>,
280 requires_tool: bool,
281 no_collision: bool,
282 slipperiness: Option<f64>,
283 shape: Option<[f64; 6]>,
285 connect_groups: Vec<String>,
286 connects: bool,
287 inventory: Option<String>,
289 redstone: bool,
291}
292
293#[derive(Debug, Clone, Default)]
294struct ItemProps {
295 stack_size: Option<u8>,
296 durability: u32,
297 fire_resistant: bool,
298 fuel_ticks: u32,
299 food: Option<(u32, f64, bool)>,
301}
302
303#[derive(Debug, Clone)]
305struct Entry {
306 kind: Kind,
307 id: String,
308 file: String,
309 auto: bool,
310 block: BlockProps,
311 item: ItemProps,
312}
313
314struct Ctx<'a> {
316 root: &'a Path,
317 refs: &'a yres::Refs,
318 file: String,
319 ns: String,
320 errors: &'a mut Vec<String>,
321 warnings: &'a mut Vec<String>,
322 textures: &'a mut BTreeMap<String, PathBuf>,
324 inventories: &'a mut Vec<(String, usize, String)>,
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 unknown_keys(&mut self, obj: &J, what: &str, known: &[&str]) {
340 for (k, v) in obj.members() {
341 if !known.contains(&k.as_str()) && !is_metadata(k) {
342 self.err(
343 v.line,
344 format!(
345 "unknown key {k:?} in {what} (expected one of: {})",
346 known.join(", ")
347 ),
348 );
349 }
350 }
351 }
352
353 fn number(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<f64> {
354 match j.num() {
355 Some(n) if (min..=max).contains(&n) => Some(n),
356 Some(n) => {
357 self.err(j.line, format!("{what} is {n}, must be {min}..{max}"));
358 None
359 }
360 None => {
361 self.err(j.line, format!("{what} must be a number, not {}", j.kind()));
362 None
363 }
364 }
365 }
366
367 fn boolean(&mut self, j: &J, what: &str) -> Option<bool> {
368 let b = j.bool();
369 if b.is_none() {
370 self.err(j.line, format!("{what} must be true or false"));
371 }
372 b
373 }
374
375 fn string<'j>(&mut self, j: &'j J, what: &str) -> Option<&'j str> {
376 let s = j.str();
377 if s.is_none() {
378 self.err(j.line, format!("{what} must be a string, not {}", j.kind()));
379 }
380 s
381 }
382
383 fn one_of(&mut self, j: &J, what: &str, allowed: &[&str]) -> Option<String> {
384 let s = self.string(j, what)?;
385 if allowed.contains(&s) {
386 Some(s.to_string())
387 } else {
388 self.err(
389 j.line,
390 format!("{what} {s:?} isn't one of: {}", allowed.join(", ")),
391 );
392 None
393 }
394 }
395
396 fn vec<const N: usize>(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<[f64; N]> {
397 let items = j.items();
398 if !j.is_arr() || items.len() != N {
399 self.err(j.line, format!("{what} must be an array of {N} numbers"));
400 return None;
401 }
402 let mut out = [0.0; N];
403 for (i, item) in items.iter().enumerate() {
404 out[i] = self.number(item, what, min, max)?;
405 }
406 Some(out)
407 }
408
409 fn text(&mut self, j: &J, what: &str) -> Option<Text> {
412 let s = self.string(j, what)?;
413 match s.strip_prefix('@') {
414 Some(rest) if rest.starts_with('@') => Some(Text::Literal(rest.to_string())),
415 Some(rest) => match self.refs.texts.get(rest) {
416 Some((key, _)) => Some(Text::Key(key.clone())),
417 None => {
418 self.err(
419 j.line,
420 format!("{what} `{s}`: no text `{rest}` in resources/*.yres"),
421 );
422 None
423 }
424 },
425 None => Some(Text::Literal(s.to_string())),
426 }
427 }
428
429 fn texture(&mut self, j: &J, what: &str) -> Option<String> {
433 let s = self.string(j, what)?.trim().to_string();
434 if let Some(slot) = s.strip_prefix('#') {
435 if slot.is_empty() {
436 self.err(j.line, format!("{what}: `#` needs a slot name"));
437 return None;
438 }
439 return Some(s);
440 }
441 if let Some(rest) = s.strip_prefix('@') {
442 return match self.refs.images.get(rest) {
443 Some(id) => Some(id.clone()),
444 None => {
445 self.err(
446 j.line,
447 format!("{what} `{s}`: no image `{rest}` in resources/*.yres"),
448 );
449 None
450 }
451 };
452 }
453 if let Some(stem) = s.strip_suffix(".png") {
454 let rel = stem.trim_start_matches("./").trim_start_matches('/');
455 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
456 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
457 self.err(
458 j.line,
459 format!("{what} `{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"),
460 );
461 return None;
462 }
463 let file = self
464 .root
465 .join(yres::RESOURCES_DIR)
466 .join(format!("{rel}.png"));
467 if !file.is_file() {
468 self.err(j.line, format!("{what}: resources/{rel}.png doesn't exist"));
469 return None;
470 }
471 let target = format!("assets/{}/textures/{rel}.png", self.ns);
472 if let Some(other) = self.textures.insert(target.clone(), file.clone())
473 && other != file
474 {
475 self.err(
476 j.line,
477 format!(
478 "{what}: {target} also comes from {}",
479 display(self.root, &other)
480 ),
481 );
482 }
483 let meta = self
484 .root
485 .join(yres::RESOURCES_DIR)
486 .join(format!("{rel}.png.mcmeta"));
487 if meta.is_file() {
488 self.textures.insert(format!("{target}.mcmeta"), meta);
489 }
490 return Some(format!("{}:{rel}", self.ns));
491 }
492 if !valid_id(&s) {
493 self.err(
494 j.line,
495 format!("{what} `{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"),
496 );
497 return None;
498 }
499 let id = if s.contains(':') {
500 s
501 } else {
502 format!("minecraft:{s}")
503 };
504 let (ns, path) = id.split_once(':').unwrap_or_default();
505 if ns == self.ns {
506 let file = self
507 .root
508 .join("assets")
509 .join(ns)
510 .join("textures")
511 .join(format!("{path}.png"));
512 let bundled = self
513 .textures
514 .contains_key(&format!("assets/{ns}/textures/{path}.png"))
515 || self.refs.images.values().any(|v| *v == id);
516 if !file.is_file() && !bundled {
517 self.warn(
518 j.line,
519 format!("{what}: texture {id} isn't in assets/{ns}/textures/ or resources/"),
520 );
521 }
522 }
523 Some(id)
524 }
525}
526
527struct Model {
529 json: Value,
530 bounds: Option<[f64; 6]>,
532}
533
534fn model(cx: &mut Ctx, j: &J, what: &str, item: bool) -> Option<Model> {
537 if !j.is_obj() {
538 cx.err(j.line, format!("{what} must be an object"));
539 return None;
540 }
541 cx.unknown_keys(
542 j,
543 what,
544 &[
545 "parent",
546 "textures",
547 "elements",
548 "display",
549 "ambient_occlusion",
550 "gui_light",
551 ],
552 );
553 let mut out = Map::new();
554 let mut tpl: Option<&Template> = None;
555 let mut raw_parent = false;
556 if let Some(p) = j.get("parent") {
557 let name = cx.string(p, &format!("{what}.parent"))?;
558 if let Some(t) = template(name) {
559 if t.sprite && !item {
560 cx.err(p.line, format!("{what}.parent {name:?} is an item sprite — block models use cube_all, cube_column, ..."));
561 }
562 out.insert("parent".into(), Value::String(t.parent.into()));
563 tpl = Some(t);
564 } else if name.contains(':') && valid_id(name) {
565 out.insert("parent".into(), Value::String(name.into()));
566 raw_parent = true;
567 } else {
568 let names: Vec<&str> = TEMPLATES
569 .iter()
570 .filter(|t| item || !t.sprite)
571 .map(|t| t.name)
572 .collect();
573 cx.err(
574 p.line,
575 format!("{what}.parent {name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")),
576 );
577 return None;
578 }
579 }
580
581 let mut slots: Vec<String> = Vec::new();
583 let mut textures = Map::new();
584 if let Some(t) = j.get("textures") {
585 if !t.is_obj() {
586 cx.err(
587 t.line,
588 format!("{what}.textures must be an object of slot → texture"),
589 );
590 }
591 for (slot, v) in t.members() {
592 if slot.is_empty() || slot.starts_with('#') {
593 cx.err(
594 v.line,
595 format!("{what}.textures: slot names are plain words (`all`, `side`, `0`)"),
596 );
597 continue;
598 }
599 if let Some(tex) = cx.texture(v, &format!("{what}.textures.{slot}")) {
600 textures.insert(slot.clone(), Value::String(tex));
601 slots.push(slot.clone());
602 }
603 }
604 }
605 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
606 if let Some(t) = tpl {
607 if t.sprite {
608 if !defined("layer0", &textures) {
609 cx.err(
610 j.line,
611 format!("{what}: parent {:?} needs textures.layer0", t.name),
612 );
613 }
614 for slot in &slots {
615 let n = slot
616 .strip_prefix("layer")
617 .and_then(|n| n.parse::<usize>().ok());
618 match n {
619 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
620 Some(_) => cx.err(j.line, format!("{what}: textures.{slot} without the layer before it")),
621 None if slot == "particle" => {}
622 None => cx.warn(j.line, format!("{what}: textures.{slot} isn't used by an item sprite (layer0, layer1, ...)")),
623 }
624 }
625 } else {
626 for slot in t.slots {
627 if !defined(slot, &textures) {
628 cx.err(
629 j.line,
630 format!("{what}: parent {:?} needs textures.{slot}", t.name),
631 );
632 }
633 }
634 }
635 }
636
637 let mut bounds: Option<[f64; 6]> = None;
639 if let Some(e) = j.get("elements") {
640 if let Some(t) = tpl {
641 cx.err(
642 e.line,
643 format!(
644 "{what}: elements replace the shape of parent {:?} — remove one of them",
645 t.name
646 ),
647 );
648 }
649 if !e.is_arr() {
650 cx.err(e.line, format!("{what}.elements must be an array"));
651 }
652 let mut elements = Vec::new();
653 for (i, el) in e.items().iter().enumerate() {
654 if let Some((json, from, to)) = element(
655 cx,
656 el,
657 &format!("{what}.elements[{i}]"),
658 &textures,
659 raw_parent,
660 ) {
661 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
662 for a in 0..3 {
663 b[a] = b[a].min(from[a].min(to[a]));
664 b[a + 3] = b[a + 3].max(from[a].max(to[a]));
665 }
666 elements.push(json);
667 }
668 }
669 out.insert("elements".into(), Value::Array(elements));
670 } else if tpl.is_none() && !raw_parent {
671 cx.err(j.line, format!("{what}: no parent and no elements — give a template (\"parent\": \"cube_all\") or elements"));
672 }
673
674 if !textures.contains_key("particle")
678 && !tpl.is_some_and(|t| t.has_particle)
679 && (!raw_parent || j.get("elements").is_some())
680 && let Some(first) = slots.first()
681 {
682 let v = textures[first].clone();
683 textures.insert("particle".into(), v);
684 }
685 if !textures.is_empty() {
686 out.insert("textures".into(), Value::Object(textures));
687 }
688
689 if let Some(d) = j.get("display") {
690 if !d.is_obj() {
691 cx.err(d.line, format!("{what}.display must be an object"));
692 }
693 let mut display = Map::new();
694 for (slot, t) in d.members() {
695 let w = format!("{what}.display.{slot}");
696 if !DISPLAY_SLOTS.contains(&slot.as_str()) {
697 cx.err(
698 t.line,
699 format!(
700 "{w}: unknown display position (expected one of: {})",
701 DISPLAY_SLOTS.join(", ")
702 ),
703 );
704 continue;
705 }
706 cx.unknown_keys(t, &w, &["rotation", "translation", "scale"]);
707 let mut m = Map::new();
708 for (key, lim) in [("rotation", 360.0), ("translation", 80.0), ("scale", 4.0)] {
709 if let Some(v) = t.get(key)
710 && let Some(v3) = cx.vec::<3>(v, &format!("{w}.{key}"), -lim, lim)
711 {
712 m.insert(
713 key.into(),
714 Value::Array(v3.iter().map(|n| num_value(*n)).collect()),
715 );
716 }
717 }
718 display.insert(slot.clone(), Value::Object(m));
719 }
720 out.insert("display".into(), Value::Object(display));
721 }
722 if let Some(ao) = j.get("ambient_occlusion")
723 && let Some(b) = cx.boolean(ao, &format!("{what}.ambient_occlusion"))
724 {
725 out.insert("ambientocclusion".into(), Value::Bool(b));
726 }
727 if let Some(gl) = j.get("gui_light")
728 && let Some(s) = cx.one_of(gl, &format!("{what}.gui_light"), &["front", "side"])
729 {
730 out.insert("gui_light".into(), Value::String(s));
731 }
732 Some(Model {
733 json: Value::Object(out),
734 bounds,
735 })
736}
737
738fn element(
740 cx: &mut Ctx,
741 j: &J,
742 what: &str,
743 textures: &Map<String, Value>,
744 raw_parent: bool,
745) -> Option<(Value, [f64; 3], [f64; 3])> {
746 if !j.is_obj() {
747 cx.err(j.line, format!("{what} must be an object"));
748 return None;
749 }
750 cx.unknown_keys(
751 j,
752 what,
753 &["name", "from", "to", "rotation", "shade", "faces"],
754 );
755 if let Some(n) = j.get("name") {
756 cx.string(n, &format!("{what}.name"));
757 }
758 let (Some(from_j), Some(to_j)) = (j.get("from"), j.get("to")) else {
759 cx.err(j.line, format!("{what} needs `from` and `to`"));
760 return None;
761 };
762 let from = cx.vec::<3>(from_j, &format!("{what}.from"), -16.0, 32.0)?;
763 let to = cx.vec::<3>(to_j, &format!("{what}.to"), -16.0, 32.0)?;
764 let mut out = Map::new();
765 out.insert(
766 "from".into(),
767 Value::Array(from.iter().map(|n| num_value(*n)).collect()),
768 );
769 out.insert(
770 "to".into(),
771 Value::Array(to.iter().map(|n| num_value(*n)).collect()),
772 );
773 if let Some(r) = j.get("rotation") {
774 let w = format!("{what}.rotation");
775 cx.unknown_keys(r, &w, &["origin", "axis", "angle", "rescale"]);
776 let axis = r
777 .get("axis")
778 .and_then(|a| cx.one_of(a, &format!("{w}.axis"), &["x", "y", "z"]));
779 let angle = r
780 .get("angle")
781 .and_then(|a| cx.number(a, &format!("{w}.angle"), -45.0, 45.0));
782 if let Some(a) = angle
783 && (a / 22.5).fract() != 0.0
784 {
785 cx.err(r.line, format!("{w}.angle is {a} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees"));
786 }
787 let origin = match r.get("origin") {
788 Some(o) => cx.vec::<3>(o, &format!("{w}.origin"), -16.0, 32.0),
789 None => Some([8.0, 8.0, 8.0]),
790 };
791 if axis.is_none() || angle.is_none() {
792 if r.get("axis").is_none() || r.get("angle").is_none() {
793 cx.err(r.line, format!("{w} needs `axis` and `angle`"));
794 }
795 } else if let (Some(axis), Some(angle), Some(origin)) = (axis, angle, origin) {
796 let mut rot = Map::new();
797 rot.insert(
798 "origin".into(),
799 Value::Array(origin.iter().map(|n| num_value(*n)).collect()),
800 );
801 rot.insert("axis".into(), Value::String(axis));
802 rot.insert("angle".into(), num_value(angle));
803 if let Some(rs) = r.get("rescale")
804 && let Some(b) = cx.boolean(rs, &format!("{w}.rescale"))
805 && b
806 {
807 rot.insert("rescale".into(), Value::Bool(true));
808 }
809 out.insert("rotation".into(), Value::Object(rot));
810 }
811 }
812 if let Some(s) = j.get("shade")
813 && let Some(b) = cx.boolean(s, &format!("{what}.shade"))
814 && !b
815 {
816 out.insert("shade".into(), Value::Bool(false));
817 }
818 let mut faces = Map::new();
819 match j.get("faces") {
820 None => cx.warn(j.line, format!("{what} has no faces — it's invisible")),
821 Some(f) => {
822 if !f.is_obj() {
823 cx.err(
824 f.line,
825 format!("{what}.faces must be an object of direction → face"),
826 );
827 }
828 for (dir, face) in f.members() {
829 let w = format!("{what}.faces.{dir}");
830 if !DIRECTIONS.contains(&dir.as_str()) {
831 cx.err(
832 face.line,
833 format!(
834 "{w}: unknown face (expected one of: {})",
835 DIRECTIONS.join(", ")
836 ),
837 );
838 continue;
839 }
840 cx.unknown_keys(face, &w, &["texture", "uv", "rotation", "cullface", "tint"]);
841 let mut fo = Map::new();
842 match face.get("texture") {
843 None => cx.err(face.line, format!("{w} needs a texture (`#slot`)")),
844 Some(t) => {
845 if let Some(s) = cx.string(t, &format!("{w}.texture")) {
846 let slot = s.strip_prefix('#').unwrap_or(s);
847 if !textures.contains_key(slot) && !raw_parent {
848 cx.err(
849 t.line,
850 format!("{w}.texture `#{slot}`: no such slot in textures"),
851 );
852 }
853 fo.insert("texture".into(), Value::String(format!("#{slot}")));
854 }
855 }
856 }
857 if let Some(uv) = face.get("uv")
858 && let Some(uv) = cx.vec::<4>(uv, &format!("{w}.uv"), 0.0, 16.0)
859 {
860 fo.insert(
861 "uv".into(),
862 Value::Array(uv.iter().map(|n| num_value(*n)).collect()),
863 );
864 }
865 if let Some(r) = face.get("rotation") {
866 match r.num() {
867 Some(n) if [0.0, 90.0, 180.0, 270.0].contains(&n) => {
868 if n != 0.0 {
869 fo.insert("rotation".into(), num_value(n));
870 }
871 }
872 _ => cx.err(r.line, format!("{w}.rotation must be 0, 90, 180 or 270")),
873 }
874 }
875 if let Some(c) = face.get("cullface")
876 && let Some(c) = cx.one_of(c, &format!("{w}.cullface"), &DIRECTIONS)
877 {
878 fo.insert("cullface".into(), Value::String(c));
879 }
880 if let Some(t) = face.get("tint")
881 && let Some(n) = cx.number(t, &format!("{w}.tint"), -1.0, 255.0)
882 && n >= 0.0
883 {
884 fo.insert("tintindex".into(), num_value(n.floor()));
885 }
886 faces.insert(dir.clone(), Value::Object(fo));
887 }
888 }
889 }
890 out.insert("faces".into(), Value::Object(faces));
891 Some((Value::Object(out), from, to))
892}
893
894fn entry_id(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
896 let (raw, line) = match j.get("id") {
897 Some(v) => (cx.string(v, "id")?.to_string(), v.line),
898 None => (
899 path.file_stem()
900 .unwrap_or_default()
901 .to_string_lossy()
902 .into_owned(),
903 1,
904 ),
905 };
906 let (ns, p) = match raw.split_once(':') {
907 Some((ns, p)) => (ns.to_string(), p.to_string()),
908 None => match mod_ns {
909 Some(ns) => (ns.to_string(), raw.clone()),
910 None => {
911 cx.err(
912 line,
913 "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"",
914 );
915 return None;
916 }
917 },
918 };
919 if !valid_namespace(&ns) || !valid_path(&p) {
920 cx.err(
921 line,
922 format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"),
923 );
924 return None;
925 }
926 Some((ns, p))
927}
928
929fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
931 let mut v = Map::new();
932 v.insert("model".into(), Value::String(model.into()));
933 if x != 0.0 {
934 v.insert("x".into(), num_value(x));
935 }
936 if y != 0.0 {
937 v.insert("y".into(), num_value(y));
938 }
939 if uvlock {
940 v.insert("uvlock".into(), Value::Bool(true));
941 }
942 if let Some(w) = weight
943 && w != 1.0
944 {
945 v.insert("weight".into(), num_value(w));
946 }
947 Value::Object(v)
948}
949
950fn pretty(v: &Value) -> Vec<u8> {
951 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
952 s.push('\n');
953 s.into_bytes()
954}
955
956#[allow(clippy::too_many_arguments)]
958fn compile_file(
959 cx: &mut Ctx,
960 j: &J,
961 kind: Kind,
962 path: &Path,
963 mod_ns: Option<&str>,
964 out: &mut Compiled,
965 generated: &mut BTreeMap<String, String>,
966) -> Option<Entry> {
967 if !j.is_obj() {
968 cx.err(
969 j.line,
970 format!("a .y{} file is one JSON object", kind.word()),
971 );
972 return None;
973 }
974 let common = [
975 "$schema", "format", "id", "name", "tooltip", "register", "model",
976 ];
977 let specific: &[&str] = match kind {
978 Kind::Block => &["block", "item_model", "states"],
979 Kind::Item => &["item"],
980 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
981 };
982 let known: Vec<&str> = common.iter().chain(specific).copied().collect();
983 cx.unknown_keys(j, &format!("a .y{}", kind.word()), &known);
984 if let Some(f) = j.get("format")
985 && f.num() != Some(1.0)
986 {
987 cx.err(f.line, "format must be 1 (this yog understands format 1)");
988 }
989 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
990 cx.ns = ns.clone();
991 let id = format!("{ns}:{p}");
992
993 let auto = match j.get("register") {
994 None => true,
995 Some(r) => cx
996 .one_of(r, "register", &["auto", "manual"])
997 .is_none_or(|s| s == "auto"),
998 };
999
1000 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
1002 for (field, target) in [("name", key.clone()), ("tooltip", format!("{key}.desc"))] {
1003 let Some(v) = j.get(field) else { continue };
1004 match cx.text(v, field)? {
1005 Text::Key(from) if from == target => {}
1006 Text::Key(from) => out.lang_aliases.push((from, target)),
1007 Text::Literal(s) => {
1008 out.lang
1009 .entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string()))
1010 .or_default()
1011 .insert(target, s);
1012 }
1013 }
1014 }
1015
1016 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
1017 let full = format!("assets/{ns}/{rel}");
1018 if cx.root.join(&full).is_file() {
1019 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
1020 }
1021 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
1022 cx.err(1, format!("{full} is also written by {other}"));
1023 }
1024 out.outputs.push((full, pretty(value)));
1025 };
1026
1027 let mut entry = Entry {
1028 kind,
1029 id: id.clone(),
1030 file: cx.file.clone(),
1031 auto,
1032 block: BlockProps::default(),
1033 item: ItemProps::default(),
1034 };
1035
1036 match kind {
1037 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
1038 Kind::Item => {
1039 if let Some(it) = j.get("item") {
1040 cx.unknown_keys(
1041 it,
1042 "item",
1043 &[
1044 "stack_size",
1045 "durability",
1046 "fire_resistant",
1047 "fuel_ticks",
1048 "food",
1049 ],
1050 );
1051 let props = &mut entry.item;
1052 if let Some(v) = it.get("stack_size") {
1053 props.stack_size = cx.number(v, "item.stack_size", 1.0, 99.0).map(|n| n as u8);
1054 }
1055 if let Some(v) = it.get("durability") {
1056 props.durability =
1057 cx.number(v, "item.durability", 0.0, 1e9).unwrap_or(0.0) as u32;
1058 }
1059 if let Some(v) = it.get("fire_resistant") {
1060 props.fire_resistant = cx.boolean(v, "item.fire_resistant").unwrap_or(false);
1061 }
1062 if let Some(v) = it.get("fuel_ticks") {
1063 props.fuel_ticks =
1064 cx.number(v, "item.fuel_ticks", 0.0, 1e9).unwrap_or(0.0) as u32;
1065 }
1066 if let Some(f) = it.get("food") {
1067 cx.unknown_keys(
1068 f,
1069 "item.food",
1070 &["nutrition", "saturation", "always_edible"],
1071 );
1072 let n = f
1073 .get("nutrition")
1074 .and_then(|v| cx.number(v, "item.food.nutrition", 0.0, 1000.0));
1075 let s = f
1076 .get("saturation")
1077 .and_then(|v| cx.number(v, "item.food.saturation", 0.0, 100.0));
1078 let a = f
1079 .get("always_edible")
1080 .and_then(|v| cx.boolean(v, "item.food.always_edible"));
1081 if n.is_none() {
1082 cx.err(f.line, "item.food needs nutrition");
1083 }
1084 props.food = n.map(|n| (n as u32, s.unwrap_or(0.0), a.unwrap_or(false)));
1085 }
1086 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
1087 cx.warn(
1088 it.line,
1089 "item.stack_size is ignored: an item with durability stacks to 1",
1090 );
1091 }
1092 }
1093 let Some(mj) = j.get("model") else {
1094 cx.err(j.line, "an item needs a model — e.g. { \"parent\": \"generated\", \"textures\": { \"layer0\": \"@Textures.ruby\" } }");
1095 return Some(entry);
1096 };
1097 let m = model(cx, mj, "model", true)?;
1098 emit(cx, format!("models/item/{p}.json"), &m.json);
1099 emit(
1100 cx,
1101 format!("items/{p}.json"),
1102 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1103 );
1104 }
1105 Kind::Block => {
1106 let mut render_layer: Option<String> = None;
1107 let mut drops = Value::Null;
1108 let mut shape_auto = true;
1109 if let Some(b) = j.get("block") {
1110 cx.unknown_keys(
1111 b,
1112 "block",
1113 &[
1114 "hardness",
1115 "resistance",
1116 "sound",
1117 "light",
1118 "tool",
1119 "tool_level",
1120 "requires_tool",
1121 "collision",
1122 "slipperiness",
1123 "shape",
1124 "render_layer",
1125 "drops",
1126 "connect_groups",
1127 "inventory",
1128 "redstone",
1129 ],
1130 );
1131 let props = &mut entry.block;
1132 if let Some(v) = b.get("hardness") {
1133 props.hardness = cx.number(v, "block.hardness", -1.0, 1e6);
1134 }
1135 if let Some(v) = b.get("resistance") {
1136 props.resistance = cx.number(v, "block.resistance", 0.0, 1e7);
1137 }
1138 if let Some(v) = b.get("sound") {
1139 props.sound = cx.one_of(v, "block.sound", &SOUNDS);
1140 }
1141 if let Some(v) = b.get("light") {
1142 props.light = cx.number(v, "block.light", 0.0, 15.0).unwrap_or(0.0) as u8;
1143 }
1144 if let Some(v) = b.get("tool") {
1145 props.tool = cx.one_of(v, "block.tool", &TOOLS);
1146 }
1147 if let Some(v) = b.get("tool_level") {
1148 props.tool_level = cx.one_of(v, "block.tool_level", &TOOL_LEVELS);
1149 }
1150 if let Some(v) = b.get("requires_tool") {
1151 props.requires_tool = cx.boolean(v, "block.requires_tool").unwrap_or(false);
1152 }
1153 if let Some(v) = b.get("collision") {
1154 props.no_collision = !cx.boolean(v, "block.collision").unwrap_or(true);
1155 }
1156 if let Some(v) = b.get("slipperiness") {
1157 props.slipperiness = cx.number(v, "block.slipperiness", 0.0, 1.0);
1158 }
1159 if let Some(v) = b.get("shape") {
1160 match &v.v {
1161 V::Str(s) if s == "auto" => {}
1162 V::Str(s) if s == "full" => shape_auto = false,
1163 V::Arr(_) => {
1164 shape_auto = false;
1165 props.shape = cx.vec::<6>(v, "block.shape", 0.0, 16.0);
1166 }
1167 _ => cx.err(v.line, "block.shape must be \"auto\" (the model's bounds), \"full\" or [x1, y1, z1, x2, y2, z2] in pixels"),
1168 }
1169 }
1170 if let Some(v) = b.get("render_layer") {
1171 render_layer = cx.one_of(v, "block.render_layer", &RENDER_LAYERS);
1172 }
1173 if let Some(v) = b.get("redstone") {
1174 props.redstone = cx.boolean(v, "block.redstone").unwrap_or(false);
1175 }
1176 if let Some(v) = b.get("inventory")
1177 && let Some(id) = cx.string(v, "block.inventory")
1178 {
1179 if valid_id(id) && id.contains(':') {
1180 props.inventory = Some(id.to_string());
1181 let at = (cx.file.clone(), v.line, id.to_string());
1182 cx.inventories.push(at);
1183 } else {
1184 cx.err(v.line, format!("block.inventory `{id}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
1185 }
1186 }
1187 if let Some(v) = b.get("connect_groups") {
1188 for g in v.items() {
1189 if let Some(s) = cx.string(g, "block.connect_groups[]") {
1190 props.connect_groups.push(s.to_string());
1191 }
1192 }
1193 if !v.is_arr() {
1194 cx.err(
1195 v.line,
1196 "block.connect_groups must be an array of group names",
1197 );
1198 }
1199 }
1200 if props.tool_level.is_some() && props.tool.is_none() {
1201 cx.warn(
1202 b.line,
1203 "block.tool_level without block.tool — any tool of that level mines it",
1204 );
1205 }
1206 if let Some(d) = b.get("drops") {
1207 drops = drops_table(cx, d, &id);
1208 }
1209 }
1210 if drops.is_null() && !j.get("block").and_then(|b| b.get("drops")).is_some() {
1211 drops = drops_table_self(&id);
1212 }
1213
1214 let Some(mj) = j.get("model") else {
1215 cx.err(j.line, "a block needs a model — e.g. { \"parent\": \"cube_all\", \"textures\": { \"all\": \"@Textures.ruby_block\" } }");
1216 return Some(entry);
1217 };
1218 let mut m = model(cx, mj, "model", false)?;
1219 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1220 o.insert(
1222 "render_type".into(),
1223 Value::String(format!("minecraft:{layer}")),
1224 );
1225 }
1226 if shape_auto {
1227 entry.block.shape = m
1228 .bounds
1229 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
1230 .map(|b| {
1231 [
1232 b[0].max(0.0),
1233 b[1].max(0.0),
1234 b[2].max(0.0),
1235 b[3].min(16.0),
1236 b[4].min(16.0),
1237 b[5].min(16.0),
1238 ]
1239 });
1240 }
1241 let main = format!("{ns}:block/{p}");
1242 emit(cx, format!("models/block/{p}.json"), &m.json);
1243
1244 let mut rotations: Vec<Value> = Vec::new();
1246 let mut arm: Option<(String, bool)> = None;
1247 if let Some(s) = j.get("states") {
1248 cx.unknown_keys(s, "states", &["rotations", "connect"]);
1249 if let Some(r) = s.get("rotations") {
1250 if !r.is_arr() || r.items().is_empty() {
1251 cx.err(r.line, "states.rotations must be a non-empty array of { \"x\": 0, \"y\": 90, \"weight\": 1, \"uvlock\": false }");
1252 }
1253 for (i, rot) in r.items().iter().enumerate() {
1254 let w = format!("states.rotations[{i}]");
1255 cx.unknown_keys(rot, &w, &["x", "y", "weight", "uvlock"]);
1256 let mut angle = |k: &str| -> f64 {
1257 match rot.get(k).map(|v| (v, v.num())) {
1258 None => 0.0,
1259 Some((_, Some(n))) if [0.0, 90.0, 180.0, 270.0].contains(&n) => n,
1260 Some((v, _)) => {
1261 cx.err(v.line, format!("{w}.{k} must be 0, 90, 180 or 270"));
1262 0.0
1263 }
1264 }
1265 };
1266 let (x, y) = (angle("x"), angle("y"));
1267 let weight = rot
1268 .get("weight")
1269 .and_then(|v| cx.number(v, &format!("{w}.weight"), 1.0, 1e6));
1270 let uvlock = rot
1271 .get("uvlock")
1272 .and_then(|v| cx.boolean(v, &format!("{w}.uvlock")))
1273 .unwrap_or(false);
1274 rotations.push(variant(&main, x, y, uvlock, weight));
1275 }
1276 }
1277 if let Some(c) = s.get("connect") {
1278 cx.unknown_keys(c, "states.connect", &["arm", "uvlock"]);
1279 let uvlock = c
1280 .get("uvlock")
1281 .and_then(|v| cx.boolean(v, "states.connect.uvlock"))
1282 .unwrap_or(false);
1283 match c.get("arm") {
1284 None => cx.err(
1285 c.line,
1286 "states.connect needs `arm` — the model of the arm towards north",
1287 ),
1288 Some(a) => {
1289 if let Some(am) = model(cx, a, "states.connect.arm", false) {
1290 let mut json = am.json;
1291 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json)
1292 {
1293 o.insert(
1294 "render_type".into(),
1295 Value::String(format!("minecraft:{layer}")),
1296 );
1297 }
1298 emit(cx, format!("models/block/{p}_arm.json"), &json);
1299 arm = Some((format!("{ns}:block/{p}_arm"), uvlock));
1300 }
1301 }
1302 }
1303 entry.block.connects = true;
1304 if entry.block.connect_groups.is_empty() {
1305 entry.block.connect_groups.push(id.clone());
1306 }
1307 }
1308 }
1309 let base: Value = match rotations.len() {
1310 0 => variant(&main, 0.0, 0.0, false, None),
1311 1 => rotations.remove(0),
1312 _ => Value::Array(rotations),
1313 };
1314 let blockstate = match &arm {
1315 None => json!({ "variants": { "": base } }),
1316 Some((arm, uvlock)) => {
1317 let mut parts = vec![json!({ "apply": base })];
1318 for (dir, x, y) in [
1319 ("north", 0.0, 0.0),
1320 ("east", 0.0, 90.0),
1321 ("south", 0.0, 180.0),
1322 ("west", 0.0, 270.0),
1323 ("up", 270.0, 0.0),
1324 ("down", 90.0, 0.0),
1325 ] {
1326 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1327 }
1328 json!({ "multipart": parts })
1329 }
1330 };
1331 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1332
1333 let item_model = match j.get("item_model") {
1335 None => Some(json!({ "parent": main })),
1336 Some(im) => model(cx, im, "item_model", true).map(|m| m.json),
1337 };
1338 if let Some(im) = item_model {
1339 emit(cx, format!("models/item/{p}.json"), &im);
1340 }
1341 emit(
1342 cx,
1343 format!("items/{p}.json"),
1344 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1345 );
1346
1347 if !drops.is_null() {
1348 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1349 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1350 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1351 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set \"drops\": \"none\""));
1352 }
1353 out.outputs.push((full, pretty(&drops)));
1354 }
1355 if let Some(tool) = &entry.block.tool {
1356 out.tags
1357 .entry(format!("data/minecraft/tags/block/mineable/{tool}.json"))
1358 .or_default()
1359 .insert(id.clone());
1360 }
1361 if let Some(level) = &entry.block.tool_level {
1362 out.tags
1363 .entry(format!("data/minecraft/tags/block/needs_{level}_tool.json"))
1364 .or_default()
1365 .insert(id.clone());
1366 }
1367 }
1368 }
1369 Some(entry)
1370}
1371
1372fn drops_table_self(id: &str) -> Value {
1373 json!({
1374 "type": "minecraft:block",
1375 "pools": [{
1376 "rolls": 1,
1377 "entries": [{ "type": "minecraft:item", "name": id }],
1378 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1379 }]
1380 })
1381}
1382
1383fn drops_table(cx: &mut Ctx, d: &J, id: &str) -> Value {
1385 match &d.v {
1386 V::Str(s) if s == "self" => drops_table_self(id),
1387 V::Str(s) if s == "none" => Value::Null,
1388 V::Obj(_) => {
1389 cx.unknown_keys(d, "block.drops", &["item", "count"]);
1390 let item = match d.get("item") {
1391 Some(i) => match cx.string(i, "block.drops.item") {
1392 Some(s) if valid_id(s) => {
1393 if s.contains(':') {
1394 s.to_string()
1395 } else {
1396 format!("minecraft:{s}")
1397 }
1398 }
1399 Some(s) => {
1400 cx.err(i.line, format!("block.drops.item `{s}` isn't an item id"));
1401 return Value::Null;
1402 }
1403 None => return Value::Null,
1404 },
1405 None => id.to_string(),
1406 };
1407 let mut entry = json!({ "type": "minecraft:item", "name": item });
1408 if let Some(c) = d.get("count") {
1409 let count = match &c.v {
1410 V::Num(_) => cx.number(c, "block.drops.count", 1.0, 64.0).map(num_value),
1411 V::Arr(_) => cx.vec::<2>(c, "block.drops.count", 0.0, 64.0).map(|[min, max]| {
1412 json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) })
1413 }),
1414 _ => {
1415 cx.err(c.line, "block.drops.count must be a number or [min, max]");
1416 None
1417 }
1418 };
1419 if let Some(count) = count {
1420 entry["functions"] =
1421 json!([{ "function": "minecraft:set_count", "count": count }]);
1422 }
1423 }
1424 json!({
1425 "type": "minecraft:block",
1426 "pools": [{
1427 "rolls": 1,
1428 "entries": [entry],
1429 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1430 }]
1431 })
1432 }
1433 _ => {
1434 cx.err(d.line, "block.drops must be \"self\", \"none\" or { \"item\": \"ns:item\", \"count\": [1, 3] }");
1435 Value::Null
1436 }
1437 }
1438}
1439
1440#[derive(Debug, Clone)]
1444enum RecipeKind {
1445 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1448 Shapeless(Vec<Vec<String>>),
1450 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1452 Stonecutting(Vec<String>),
1453 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1454 Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
1458}
1459
1460struct BookFile {
1462 id: String,
1463 json: String,
1465}
1466
1467fn compile_book(cx: &mut Ctx, text: &str, mod_ns: Option<&str>, known: &refs::Known) -> Option<BookFile> {
1476 use refs::RefKind;
1477 use yog_data::schema::{Resolve, Text};
1478 use yog_data::{Path, Ref, Res};
1479
1480 struct Check<'a> {
1481 known: &'a refs::Known,
1482 texts: &'a HashMap<String, (String, String)>,
1483 entries: HashSet<String>,
1484 categories: HashSet<String>,
1485 text_refs: bool,
1486 }
1487
1488 impl Resolve for Check<'_> {
1489 fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
1490 let own = match r.kind.as_str() {
1491 "entry" => Some(&self.entries),
1492 "category" => Some(&self.categories),
1493 _ => None,
1494 };
1495 match (own, RefKind::from_word(&r.kind)) {
1496 (Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
1497 (Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
1498 (None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
1499 (None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
1500 }
1501 }
1502
1503 fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
1504 match t {
1505 Text::Literal(s) => Ok(s.clone()),
1506 Text::NameOf(r) => {
1508 if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
1509 return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
1510 }
1511 self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
1512 let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
1513 self.text_refs = true;
1514 Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
1515 }
1516 Text::Lang(key) => {
1518 self.text_refs = true;
1519 Ok(key.clone())
1520 }
1521 Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
1522 Some((key, _)) => {
1523 self.text_refs = true;
1524 Ok(key.clone())
1525 }
1526 None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1527 },
1528 }
1529 }
1530
1531 fn words(&mut self, r: &Res) -> Option<String> {
1532 self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
1533 }
1534 }
1535
1536 let doc = match yog_data::text::parse(text) {
1538 Ok(d) => d,
1539 Err(e) => {
1540 cx.err(e.at.line as usize, e.message);
1541 return None;
1542 }
1543 };
1544 let ids = |key: &str| -> HashSet<String> {
1545 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()
1546 };
1547 let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
1548 let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
1549 Ok(read) => read.value,
1550 Err(errors) => {
1551 for e in errors.0 {
1552 let line = e.at.map_or(1, |a| a.line as usize);
1553 if e.path.is_root() {
1554 cx.err(line, e.message);
1555 } else {
1556 cx.err(line, format!("{}: {}", e.path, e.message));
1557 }
1558 }
1559 return None;
1560 }
1561 };
1562 if !book.id.contains(':') {
1563 book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
1564 }
1565 let ns = mod_ns.unwrap_or("minecraft");
1567 let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
1568 .chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
1569 .filter(|(_, id)| !id.contains(':'))
1570 .map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
1571 .collect();
1572 if !unnamespaced.is_empty() {
1573 for e in unnamespaced {
1574 cx.err(1, e);
1575 }
1576 return None;
1577 }
1578 if !valid_id(&book.id) {
1579 cx.err(1, format!("book id `{}`: not a valid id", book.id));
1580 return None;
1581 }
1582 book.i18n |= check.text_refs;
1583 Some(BookFile { id: book.id.clone(), json: book.to_json() })
1584}
1585
1586fn is_metadata(key: &str) -> bool {
1589 key.starts_with('$') || key.starts_with('_') || key == "comment" || key == "meta"
1590}
1591
1592#[derive(Debug, Clone)]
1594struct Recipe {
1595 id: String,
1596 file: String,
1597 kind: RecipeKind,
1598 result: String,
1599 count: u32,
1600 group: Option<String>,
1601 category: Option<String>,
1602 items: Vec<(usize, String)>,
1605}
1606
1607impl Recipe {
1608 fn registration(&self) -> String {
1610 let list = |items: &[String]| {
1611 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
1612 format!("vec![{}]", quoted.join(", "))
1613 };
1614 let kind = match &self.kind {
1615 RecipeKind::Shaped { pattern, key } => {
1616 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
1617 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
1618 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
1619 }
1620 RecipeKind::Shapeless(ingredients) => {
1621 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
1622 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
1623 }
1624 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
1625 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
1626 list(input),
1627 float(*experience)
1628 ),
1629 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
1630 RecipeKind::Custom { kind, slots, values } => {
1631 let mut o = Map::new();
1633 o.insert("type".into(), kind.clone().into());
1634 o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
1635 if !values.is_empty() {
1636 o.insert("values".into(), Value::Object(values.clone()));
1637 }
1638 o.insert("result".into(), self.result.clone().into());
1639 if self.count != 1 {
1640 o.insert("count".into(), self.count.into());
1641 }
1642 if let Some(g) = &self.group {
1643 o.insert("group".into(), g.clone().into());
1644 }
1645 return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
1646 }
1647 RecipeKind::Smithing { template, base, addition } => format!(
1648 "Smithing {{ template: {}, base: {}, addition: {} }}",
1649 list(template),
1650 list(base),
1651 list(addition)
1652 ),
1653 };
1654 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
1655 format!(
1656 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
1657 self.id,
1658 self.result,
1659 self.count,
1660 opt(&self.group),
1661 opt(&self.category)
1662 )
1663 }
1664}
1665
1666fn recipe_item(cx: &mut Ctx, j: &J, what: &str, tags: bool, items: &mut Vec<(usize, String)>) -> Option<String> {
1669 let s = cx.string(j, what)?.trim().to_string();
1670 let (tag, id) = match s.strip_prefix('#') {
1671 Some(rest) => (true, rest),
1672 None => (false, s.as_str()),
1673 };
1674 if tag && !tags {
1675 cx.err(j.line, format!("{what} `{s}`: must be an item, not a tag"));
1676 return None;
1677 }
1678 let ok = id.split_once(':').is_some_and(|(ns, p)| valid_namespace(ns) && valid_path(p));
1679 if !ok {
1680 let hint = if id.contains(':') { String::new() } else { format!(" — write `minecraft:{id}`") };
1681 cx.err(
1682 j.line,
1683 format!("{what} `{s}`: expected an item id with its namespace (minecraft:paper){}{hint}", if tags { " or an item tag (#c:dusts)" } else { "" }),
1684 );
1685 return None;
1686 }
1687 if !tag {
1688 items.push((j.line, id.to_string()));
1689 }
1690 Some(s)
1691}
1692
1693fn compile_custom_recipe(cx: &mut Ctx, j: &J, kind: String, id: String) -> Option<Recipe> {
1696 for key in ["pattern", "key", "ingredients", "ingredient", "experience", "cook_time", "template", "base", "addition", "category"] {
1697 if let Some(v) = j.get(key) {
1698 cx.err(v.line, format!("{key} is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
1699 }
1700 }
1701 let mut items = Vec::new();
1702 let mut slots = Vec::new();
1703 match j.get("slots") {
1704 Some(sv) if sv.is_obj() => {
1705 for (name, list) in sv.members() {
1706 if !list.is_arr() || list.items().is_empty() {
1707 cx.err(list.line, format!("slots.{name}: a list of ingredients — [\"minecraft:diamond\"], [[\"minecraft:coal\", \"minecraft:charcoal\"]]"));
1708 continue;
1709 }
1710 let ings: Vec<Vec<String>> = list
1711 .items()
1712 .iter()
1713 .filter_map(|i| recipe_ingredient(cx, i, &format!("slots.{name}[]"), &mut items))
1714 .collect();
1715 slots.push((name.clone(), ings));
1716 }
1717 }
1718 Some(sv) => cx.err(sv.line, "slots must be an object: { \"core\": [\"minecraft:diamond\"] }"),
1719 None => cx.err(j.line, format!("a {kind} recipe needs \"slots\": what goes in, by slot name")),
1720 }
1721 let values = match j.get("values") {
1722 Some(v) if v.is_obj() => v.members().iter().map(|(k, x)| (k.clone(), json_value(x))).collect(),
1723 Some(v) => {
1724 cx.err(v.line, "values must be an object: { \"time\": 200 }");
1725 Map::new()
1726 }
1727 None => Map::new(),
1728 };
1729 let result = match j.get("result") {
1730 Some(v) => recipe_item(cx, v, "result", false, &mut items),
1731 None => {
1732 cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1733 None
1734 }
1735 };
1736 let count = match j.get("count") {
1737 Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1738 None => Some(1),
1739 };
1740 let group = j.get("group").and_then(|v| cx.string(v, "group").map(str::to_owned));
1741 Some(Recipe {
1742 id,
1743 file: cx.file.clone(),
1744 kind: RecipeKind::Custom { kind, slots, values },
1745 result: result?,
1746 count: count?,
1747 group,
1748 category: None,
1749 items,
1750 })
1751}
1752
1753fn json_value(j: &J) -> Value {
1755 match &j.v {
1756 V::Null => Value::Null,
1757 V::Bool(b) => Value::Bool(*b),
1758 V::Num(n) => num_value(*n),
1759 V::Str(s) => Value::String(s.clone()),
1760 V::Arr(items) => Value::Array(items.iter().map(json_value).collect()),
1761 V::Obj(members) => Value::Object(members.iter().map(|(k, v)| (k.clone(), json_value(v))).collect()),
1762 }
1763}
1764
1765fn compile_recipe(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<Recipe> {
1767 if !j.is_obj() {
1768 cx.err(j.line, "a .yrecipe file is one JSON object");
1769 return None;
1770 }
1771 cx.unknown_keys(
1772 j,
1773 "a .yrecipe",
1774 &[
1775 "$schema", "format", "id", "type", "pattern", "key", "ingredients", "ingredient",
1776 "result", "count", "experience", "cook_time", "template", "base", "addition", "group",
1777 "category", "slots", "values",
1778 ],
1779 );
1780 if let Some(f) = j.get("format")
1781 && f.num() != Some(1.0)
1782 {
1783 cx.err(f.line, "format must be 1 (this yog understands format 1)");
1784 }
1785 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
1786 let Some(t) = j.get("type") else {
1787 cx.err(j.line, format!("a recipe needs a \"type\": {}", RECIPE_TYPES.join(", ")));
1788 return None;
1789 };
1790 if let Some(own) = t.str().filter(|ty| mod_ns.is_some_and(|m| ty.split_once(':').is_some_and(|(n, k)| n == m && valid_path(k)))) {
1792 return compile_custom_recipe(cx, j, own.to_string(), format!("{ns}:{p}"));
1793 }
1794 let ty = cx.one_of(t, "type", &RECIPE_TYPES)?;
1795 let cooking = matches!(ty.as_str(), "smelting" | "blasting" | "smoking" | "campfire_cooking");
1796 for (key, owners) in [
1798 ("pattern", &["shaped"][..]),
1799 ("key", &["shaped"]),
1800 ("ingredients", &["shapeless"]),
1801 ("ingredient", &["smelting", "blasting", "smoking", "campfire_cooking", "stonecutting"]),
1802 ("experience", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1803 ("cook_time", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1804 ("template", &["smithing"]),
1805 ("base", &["smithing"]),
1806 ("addition", &["smithing"]),
1807 ] {
1808 if !owners.contains(&ty.as_str())
1809 && let Some(v) = j.get(key)
1810 {
1811 cx.err(v.line, format!("{key} is for {} recipes, this one is {ty}", owners.join("/")));
1812 }
1813 }
1814 let mut items = Vec::new();
1815 let result = match j.get("result") {
1816 Some(v) => recipe_item(cx, v, "result", false, &mut items),
1817 None => {
1818 cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1819 None
1820 }
1821 };
1822 let count = match j.get("count") {
1823 Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1824 None => Some(1),
1825 };
1826 let text = |cx: &mut Ctx, key: &str| j.get(key).and_then(|v| cx.string(v, key).map(str::to_owned));
1827 let group = text(cx, "group");
1828 let category = text(cx, "category");
1829 let kind = match ty.as_str() {
1830 "shaped" => {
1831 let Some(pv) = j.get("pattern") else {
1832 cx.err(j.line, "a shaped recipe needs a \"pattern\": 1–3 rows of 1–3 key characters, e.g. [\"ISI\", \" S \"]");
1833 return None;
1834 };
1835 let rows: Vec<String> = pv.items().iter().filter_map(|r| cx.string(r, "pattern[]").map(str::to_owned)).collect();
1836 let width = rows.first().map_or(0, |r| r.chars().count());
1837 if !pv.is_arr() || rows.is_empty() || rows.len() > 3 || width == 0 || width > 3 || rows.iter().any(|r| r.chars().count() != width) {
1838 cx.err(pv.line, "pattern must be 1–3 rows of the same 1–3 characters each");
1839 return None;
1840 }
1841 let mut key = Vec::new();
1842 match j.get("key") {
1843 Some(kv) if kv.is_obj() => {
1844 for (k, v) in kv.members() {
1845 let mut chars = k.chars();
1846 match (chars.next(), chars.next()) {
1847 (Some(c), None) if c != ' ' => {
1848 if let Some(alts) = recipe_ingredient(cx, v, &format!("key {k:?}"), &mut items) {
1849 if !rows.iter().any(|r| r.contains(c)) {
1850 cx.warn(v.line, format!("key {k:?} isn't used by the pattern"));
1851 }
1852 key.push((c, alts));
1853 }
1854 }
1855 _ => cx.err(v.line, format!("key {k:?}: one character other than space")),
1856 }
1857 }
1858 }
1859 Some(kv) => cx.err(kv.line, "key must be an object: { \"I\": \"minecraft:iron_ingot\", ... }"),
1860 None => cx.err(j.line, "a shaped recipe needs a \"key\": what each pattern character stands for"),
1861 }
1862 for c in rows.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1863 if !key.iter().any(|(k, _)| *k == c) && j.get("key").is_some_and(|kv| kv.get(&c.to_string()).is_none()) {
1864 cx.err(pv.line, format!("pattern uses {c:?}, which key doesn't define"));
1865 return None;
1866 }
1867 }
1868 RecipeKind::Shaped { pattern: rows, key }
1869 }
1870 "shapeless" => {
1871 let Some(iv) = j.get("ingredients") else {
1872 cx.err(j.line, "a shapeless recipe needs \"ingredients\": 1–9 items or tags");
1873 return None;
1874 };
1875 let list: Vec<Vec<String>> = iv
1876 .items()
1877 .iter()
1878 .filter_map(|i| recipe_ingredient(cx, i, "ingredients[]", &mut items))
1879 .collect();
1880 if !iv.is_arr() || iv.items().is_empty() || iv.items().len() > 9 {
1881 cx.err(iv.line, "ingredients must be an array of 1–9 items or tags");
1882 return None;
1883 }
1884 RecipeKind::Shapeless(list)
1885 }
1886 "smithing" => {
1887 let mut part = |cx: &mut Ctx, key: &str| match j.get(key) {
1888 Some(v) => recipe_ingredient(cx, v, key, &mut items),
1889 None => {
1890 cx.err(j.line, format!("a smithing recipe needs a \"{key}\""));
1891 None
1892 }
1893 };
1894 let template = part(cx, "template");
1895 let base = part(cx, "base");
1896 let addition = part(cx, "addition");
1897 RecipeKind::Smithing { template: template?, base: base?, addition: addition? }
1898 }
1899 _ => {
1900 let Some(iv) = j.get("ingredient") else {
1901 cx.err(j.line, format!("a {ty} recipe needs an \"ingredient\": the item or tag it takes"));
1902 return None;
1903 };
1904 let input = recipe_ingredient(cx, iv, "ingredient", &mut items)?;
1905 if !cooking {
1906 RecipeKind::Stonecutting(input)
1907 } else {
1908 let (method, default_time) = match ty.as_str() {
1909 "smelting" => ("Smelting", 200.0),
1910 "blasting" => ("Blasting", 100.0),
1911 "smoking" => ("Smoking", 100.0),
1912 _ => ("Campfire", 600.0),
1913 };
1914 let experience = match j.get("experience") {
1915 Some(v) => cx.number(v, "experience", 0.0, 1000.0)?,
1916 None => 0.1,
1917 };
1918 let cook_time = match j.get("cook_time") {
1919 Some(v) => cx.number(v, "cook_time", 1.0, 32767.0)?,
1920 None => default_time,
1921 } as u32;
1922 RecipeKind::Cooking { method, input, experience, cook_time }
1923 }
1924 }
1925 };
1926 Some(Recipe {
1927 id: format!("{ns}:{p}"),
1928 file: cx.file.clone(),
1929 kind,
1930 result: result?,
1931 count: count?,
1932 group,
1933 category,
1934 items,
1935 })
1936}
1937
1938fn recipe_ingredient(cx: &mut Ctx, j: &J, what: &str, items: &mut Vec<(usize, String)>) -> Option<Vec<String>> {
1940 if j.is_arr() {
1941 let alts: Vec<String> = j.items().iter().filter_map(|i| recipe_item(cx, i, what, true, items)).collect();
1942 if alts.is_empty() {
1943 cx.err(j.line, format!("{what}: an array of items or tags, at least one"));
1944 return None;
1945 }
1946 return Some(alts);
1947 }
1948 recipe_item(cx, j, what, true, items).map(|i| vec![i])
1949}
1950
1951fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1954 let mut out = BTreeSet::new();
1955 let Ok(entries) = std::fs::read_dir(dir) else {
1956 return out;
1957 };
1958 for e in entries.flatten() {
1959 let path = e.path();
1960 if path.is_dir() {
1961 out.extend(inventory_screens(&path));
1962 continue;
1963 }
1964 if path.extension().is_none_or(|x| x != "yui") {
1965 continue;
1966 }
1967 let Ok(text) = std::fs::read_to_string(&path) else { continue };
1968 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
1969 continue;
1970 }
1971 if let Some(screen) = text.find("<Screen")
1972 && let Some(at) = text[screen..].find(" id=\"")
1973 {
1974 let rest = &text[screen + at + 5..];
1975 if let Some(end) = rest.find('"') {
1976 out.insert(rest[..end].to_string());
1977 }
1978 }
1979 }
1980 out
1981}
1982
1983pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1986 let mut out = Compiled::default();
1987 let files = find(root);
1988 if files.is_empty() {
1989 return Ok(out);
1990 }
1991 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1992 let mut errors: Vec<String> = Vec::new();
1993 let mut warnings: Vec<String> = Vec::new();
1994 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1995 let mut generated: BTreeMap<String, String> = BTreeMap::new();
1996 let mut entries: Vec<Entry> = Vec::new();
1997 let mut ids: HashMap<String, String> = HashMap::new();
1998 let mut recipes: Vec<Recipe> = Vec::new();
1999 let mut recipe_ids: HashMap<String, String> = HashMap::new();
2000 let mut books: Vec<BookFile> = Vec::new();
2001 let mut book_ids: HashMap<String, String> = HashMap::new();
2002 let mut inventories: Vec<(String, usize, String)> = Vec::new();
2003 let mut pending_books: Vec<(String, String)> = Vec::new();
2004 for path in &files {
2005 let shown = display(root, path);
2006 let Some(kind) = Kind::of(path) else { continue };
2007 let text = match std::fs::read_to_string(path) {
2008 Ok(t) => t,
2009 Err(e) => {
2010 errors.push(format!("{shown}: {e}"));
2011 continue;
2012 }
2013 };
2014 if kind == Kind::Book {
2015 pending_books.push((shown.clone(), text));
2017 continue;
2018 }
2019 let j = match json::parse(&text) {
2020 Ok(j) => j,
2021 Err(e) => {
2022 errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
2023 continue;
2024 }
2025 };
2026 let mut cx = Ctx {
2027 root,
2028 refs,
2029 file: shown.clone(),
2030 ns: mod_ns.unwrap_or("minecraft").to_string(),
2031 errors: &mut errors,
2032 warnings: &mut warnings,
2033 textures: &mut textures,
2034 inventories: &mut inventories,
2035 };
2036 if kind == Kind::Recipe {
2037 if let Some(r) = compile_recipe(&mut cx, &j, path, mod_ns) {
2038 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2039 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2040 }
2041 recipes.push(r);
2042 }
2043 continue;
2044 }
2045 if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
2046 {
2047 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2048 errors.push(format!(
2049 "{shown}: id {} is also defined by {other}",
2050 entry.id
2051 ));
2052 }
2053 entries.push(entry);
2054 }
2055 }
2056 let externals = crate::manifest::YogToml::read(&root.join("yog.toml")).map(|t| t.externals).unwrap_or_default();
2058 let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals);
2059 for e in &entries {
2060 known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2061 }
2062 for r in &recipes {
2063 known.add(refs::RefKind::Recipe, r.id.clone());
2064 }
2065 for (shown, text) in &pending_books {
2066 let mut cx = Ctx {
2067 root,
2068 refs,
2069 file: shown.clone(),
2070 ns: mod_ns.unwrap_or("minecraft").to_string(),
2071 errors: &mut errors,
2072 warnings: &mut warnings,
2073 textures: &mut textures,
2074 inventories: &mut inventories,
2075 };
2076 if let Some(b) = compile_book(&mut cx, text, mod_ns, &known) {
2077 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2078 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2079 }
2080 books.push(b);
2081 }
2082 }
2083 if let Some(ns) = mod_ns {
2086 for r in &recipes {
2087 for (line, item) in &r.items {
2088 if item.split_once(':').is_some_and(|(n, _)| n == ns) && !ids.contains_key(item) {
2089 errors.push(format!(
2090 "{}:{line}: no block or item {item} in {CONTENT_DIR}/",
2091 r.file
2092 ));
2093 }
2094 }
2095 }
2096 let screens = inventory_screens(&root.join("src"));
2097 for (file, line, id) in &inventories {
2098 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2099 errors.push(format!(
2100 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2101 ));
2102 }
2103 }
2104 }
2105 for (target, source) in &textures {
2106 match std::fs::read(source) {
2107 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2108 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2109 }
2110 }
2111 out.warnings = warnings;
2112 if !errors.is_empty() {
2113 return Err(format!(
2114 "invalid blocks/items in content/:\n {}",
2115 errors.join("\n ")
2116 ));
2117 }
2118 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2119 out.items = entries.len() - out.blocks;
2120 out.recipes = recipes.len();
2121 out.books = books.len();
2122 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty())
2123 && !mentions_content_macro(&root.join("src"))
2124 {
2125 out.warnings.push(format!(
2126 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2127 CONTENT_DIR
2128 ));
2129 }
2130 out.source = Some(rust_source(&entries, &recipes, &books));
2131 Ok(out)
2132}
2133
2134fn mentions_content_macro(dir: &Path) -> bool {
2136 let Ok(entries) = std::fs::read_dir(dir) else {
2137 return false;
2138 };
2139 entries.flatten().any(|e| {
2140 let p = e.path();
2141 if p.is_dir() {
2142 mentions_content_macro(&p)
2143 } else {
2144 p.extension().is_some_and(|x| x == "rs")
2145 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
2146 }
2147 })
2148}
2149
2150fn rust_ident(path: &str) -> String {
2152 let mut s: String = path
2153 .chars()
2154 .map(|c| {
2155 if c.is_ascii_alphanumeric() {
2156 c.to_ascii_lowercase()
2157 } else {
2158 '_'
2159 }
2160 })
2161 .collect();
2162 if s.starts_with(|c: char| c.is_ascii_digit()) {
2163 s.insert(0, '_');
2164 }
2165 s
2166}
2167
2168fn float(n: f64) -> String {
2169 let s = format!("{n:?}");
2170 if s.contains('.') || s.contains('e') {
2171 s
2172 } else {
2173 format!("{s}.0")
2174 }
2175}
2176
2177fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile]) -> String {
2178 let mut code = String::from(
2179 "// <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",
2180 );
2181 for (kind, set, def) in [
2182 (Kind::Block, "Blocks", "BlockDef"),
2183 (Kind::Item, "Items", "ItemDef"),
2184 ] {
2185 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
2186 let _ = writeln!(
2187 code,
2188 "\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} {{",
2189 if kind == Kind::Block {
2190 "blocks"
2191 } else {
2192 "items"
2193 }
2194 );
2195 let mut used: HashMap<String, usize> = HashMap::new();
2196 for e in mine {
2197 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2198 let mut ident = rust_ident(path);
2199 let n = used.entry(ident.clone()).or_insert(0);
2200 *n += 1;
2201 if *n > 1 {
2202 let _ = write!(ident, "_{n}");
2203 }
2204 let upper = ident.to_ascii_uppercase();
2205 let ident = if is_keyword(&ident) {
2206 format!("r#{ident}")
2207 } else {
2208 ident
2209 };
2210 let _ = writeln!(
2211 code,
2212 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
2213 e.id, e.file, e.id
2214 );
2215 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
2216 match kind {
2217 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2218 Kind::Block => {
2219 let p = &e.block;
2220 if p.hardness.is_some() || p.resistance.is_some() {
2221 let _ = write!(
2222 b,
2223 "\n .strength({}, {})",
2224 float(p.hardness.unwrap_or(1.5)),
2225 float(p.resistance.unwrap_or(6.0))
2226 );
2227 }
2228 if let Some(s) = &p.sound {
2229 let _ = write!(b, "\n .sound({s:?})");
2230 }
2231 if p.light > 0 {
2232 let _ = write!(b, "\n .light_level({})", p.light);
2233 }
2234 if p.requires_tool {
2235 b.push_str("\n .requires_tool()");
2236 }
2237 if p.no_collision {
2238 b.push_str("\n .no_collision()");
2239 }
2240 if let Some(s) = p.slipperiness {
2241 let _ = write!(b, "\n .slipperiness({})", float(s));
2242 }
2243 if let Some(s) = p.shape {
2244 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
2245 let _ = write!(b, "\n .shape({})", args.join(", "));
2246 }
2247 if p.connects {
2248 b.push_str("\n .connects_to_neighbors()");
2249 }
2250 if !p.connect_groups.is_empty() {
2251 let groups: Vec<String> =
2252 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
2253 let _ =
2254 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
2255 }
2256 if let Some(inv) = &p.inventory {
2257 let _ = write!(b, "\n .inventory({inv:?})");
2258 }
2259 if p.redstone {
2260 b.push_str("\n .redstone_source()");
2261 }
2262 }
2263 Kind::Item => {
2264 let p = &e.item;
2265 if let Some(s) = p.stack_size {
2266 let _ = write!(b, "\n .max_stack({s})");
2267 }
2268 if p.durability > 0 {
2269 let _ = write!(b, "\n .max_damage({})", p.durability);
2270 }
2271 if p.fire_resistant {
2272 b.push_str("\n .fire_resistant()");
2273 }
2274 if p.fuel_ticks > 0 {
2275 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
2276 }
2277 if let Some((n, s, always)) = p.food {
2278 let _ = write!(
2279 b,
2280 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
2281 float(s),
2282 if always { ".can_always_eat()" } else { "" }
2283 );
2284 }
2285 }
2286 }
2287 let _ = writeln!(
2288 code,
2289 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
2290 e.file
2291 );
2292 }
2293 code.push_str("}\n");
2294 }
2295
2296 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");
2297 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
2298 for e in entries {
2299 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2300 let mut ident = rust_ident(path);
2301 let n = used.entry((e.kind, ident.clone())).or_insert(0);
2302 *n += 1;
2303 if *n > 1 {
2304 let _ = write!(ident, "_{n}");
2305 }
2306 let ident = if is_keyword(&ident) {
2307 format!("r#{ident}")
2308 } else {
2309 ident
2310 };
2311 if !e.auto {
2312 continue;
2313 }
2314 let _ = match e.kind {
2315 Kind::Block => writeln!(code, " registry.register_block(Blocks::{ident}());"),
2316 Kind::Item => writeln!(code, " registry.register_item(Items::{ident}());"),
2317 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2318 };
2319 }
2320 for r in recipes {
2321 let _ = writeln!(code, " {}", r.registration());
2322 }
2323 for b in books {
2324 let _ = writeln!(code, " registry.register_book_json({:?}, {:?});", b.id, b.json);
2325 }
2326 code.push_str("}\n");
2327 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() {
2328 code.push_str(
2329 "\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",
2330 );
2331 }
2332 code
2333}
2334
2335fn is_keyword(s: &str) -> bool {
2336 const KW: &[&str] = &[
2337 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2338 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2339 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2340 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2341 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2342 ];
2343 KW.contains(&s)
2344}
2345
2346pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2349 let out = root.join(GENERATED_FILE);
2350 let Some(code) = &compiled.source else {
2351 let _ = std::fs::remove_file(&out);
2352 return Ok(());
2353 };
2354 crate::write_if_changed(&out, code)?;
2355 Ok(())
2356}
2357
2358#[derive(Default)]
2361pub struct OutputSet {
2362 items: Vec<Option<Output>>,
2363 at: HashMap<String, usize>,
2364}
2365
2366impl OutputSet {
2367 pub fn get(&self, path: &str) -> Option<&[u8]> {
2368 let i = *self.at.get(path)?;
2369 self.items[i].as_ref().map(|(_, b)| b.as_slice())
2370 }
2371
2372 pub fn contains(&self, path: &str) -> bool {
2373 self.at.contains_key(path)
2374 }
2375
2376 pub fn paths(&self) -> impl Iterator<Item = &String> {
2377 self.at.keys()
2378 }
2379
2380 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2382 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2383 self.items[i] = None;
2384 }
2385 self.items.push(Some((path, bytes)));
2386 }
2387
2388 pub fn into_vec(self) -> Vec<Output> {
2389 self.items.into_iter().flatten().collect()
2390 }
2391}
2392
2393impl From<Vec<Output>> for OutputSet {
2394 fn from(outputs: Vec<Output>) -> Self {
2395 let mut set = OutputSet::default();
2396 for (path, bytes) in outputs {
2397 set.put(path, bytes);
2398 }
2399 set
2400 }
2401}
2402
2403fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2406 let read;
2407 let bytes = match outputs.get(path) {
2408 Some(b) => b,
2409 None => match std::fs::read(root.join(path)) {
2410 Ok(b) => {
2411 read = b;
2412 &read
2413 }
2414 Err(_) => return Ok(Map::new()),
2415 },
2416 };
2417 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2418}
2419
2420pub fn merge_outputs(
2424 root: &Path,
2425 outputs: &mut Vec<Output>,
2426 compiled: Compiled,
2427) -> Result<(), String> {
2428 let mut set = OutputSet::from(std::mem::take(outputs));
2429 let mut errors = Vec::new();
2430 if !compiled.lang_aliases.is_empty() {
2433 let mut lang_files: BTreeSet<String> =
2434 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2435 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2436 for ns in dirs.flatten() {
2437 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2438 for f in files.flatten() {
2439 if let Ok(rel) = f.path().strip_prefix(root) {
2440 let rel = rel.to_string_lossy().replace('\\', "/");
2441 if is_lang_path(&rel) {
2442 lang_files.insert(rel);
2443 }
2444 }
2445 }
2446 }
2447 }
2448 }
2449 for path in lang_files {
2450 let mut map = match json_output(&set, root, &path) {
2451 Ok(m) => m,
2452 Err(e) => {
2453 errors.push(e);
2454 continue;
2455 }
2456 };
2457 let mut changed = false;
2458 for (from, to) in &compiled.lang_aliases {
2459 if let Some(text) = map.get(from).cloned()
2460 && !map.contains_key(to)
2461 {
2462 map.insert(to.clone(), text);
2463 changed = true;
2464 }
2465 }
2466 if changed {
2467 set.put(path, pretty(&Value::Object(map)));
2468 }
2469 }
2470 }
2471 for ((ns, locale), entries) in &compiled.lang {
2472 let path = format!("assets/{ns}/lang/{locale}.json");
2473 let mut map = match json_output(&set, root, &path) {
2474 Ok(m) => m,
2475 Err(e) => {
2476 errors.push(e);
2477 continue;
2478 }
2479 };
2480 for (k, v) in entries {
2481 map.entry(k.clone())
2482 .or_insert_with(|| Value::String(v.clone()));
2483 }
2484 set.put(path, pretty(&Value::Object(map)));
2485 }
2486 for (path, ids) in &compiled.tags {
2487 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2490 let path = if !set.contains(path) && root.join(&plural).is_file() {
2491 plural
2492 } else {
2493 path.clone()
2494 };
2495 let mut map = match json_output(&set, root, &path) {
2496 Ok(m) => m,
2497 Err(e) => {
2498 errors.push(e);
2499 continue;
2500 }
2501 };
2502 let values = map
2503 .entry("values")
2504 .or_insert_with(|| Value::Array(Vec::new()));
2505 if let Value::Array(list) = values {
2506 for id in ids {
2507 if !list.iter().any(|v| v.as_str() == Some(id)) {
2508 list.push(Value::String(id.clone()));
2509 }
2510 }
2511 }
2512 map.entry("replace").or_insert(Value::Bool(false));
2513 set.put(path, pretty(&Value::Object(map)));
2514 }
2515 for (path, bytes) in compiled.outputs {
2516 set.put(path, bytes);
2517 }
2518 *outputs = set.into_vec();
2519 if errors.is_empty() {
2520 Ok(())
2521 } else {
2522 Err(format!(
2523 "merging blocks/items into the mod's files:\n {}",
2524 errors.join("\n ")
2525 ))
2526 }
2527}
2528
2529fn is_lang_path(p: &str) -> bool {
2530 let parts: Vec<&str> = p.split('/').collect();
2531 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2532}
2533
2534#[cfg(test)]
2535mod tests {
2536 use super::*;
2537
2538 fn project(files: &[(&str, &str)]) -> PathBuf {
2539 use std::sync::atomic::{AtomicUsize, Ordering};
2540 static N: AtomicUsize = AtomicUsize::new(0);
2541 let dir = std::env::temp_dir().join(format!(
2542 "yog-content-test-{}-{}",
2543 std::process::id(),
2544 N.fetch_add(1, Ordering::SeqCst)
2545 ));
2546 let _ = std::fs::remove_dir_all(&dir);
2547 for (path, text) in files {
2548 let p = dir.join(path);
2549 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2550 std::fs::write(p, text).unwrap();
2551 }
2552 dir
2553 }
2554
2555 fn output(c: &Compiled, path: &str) -> Value {
2556 let (_, b) = c
2557 .outputs
2558 .iter()
2559 .find(|(p, _)| p == path)
2560 .unwrap_or_else(|| {
2561 panic!(
2562 "no {path} in {:?}",
2563 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2564 )
2565 });
2566 serde_json::from_slice(b).unwrap()
2567 }
2568
2569 #[test]
2570 fn compiles_a_cube_block() {
2571 let root = project(&[
2572 ("resources/block/ruby_ore.png", "png"),
2573 (
2574 "content/blocks/ruby_ore.yblock",
2575 r#"{
2576 "name": "Ruby Ore",
2577 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2578 "drops": { "item": "mymod:ruby", "count": [1, 3] } },
2579 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2580}"#,
2581 ),
2582 ]);
2583 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2584 assert_eq!(c.blocks, 1);
2585 assert_eq!(
2586 output(&c, "assets/mymod/models/block/ruby_ore.json"),
2587 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2588 );
2589 assert_eq!(
2590 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2591 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2592 );
2593 assert_eq!(
2594 output(&c, "assets/mymod/models/item/ruby_ore.json"),
2595 json!({ "parent": "mymod:block/ruby_ore" })
2596 );
2597 assert_eq!(
2598 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2599 json!("mymod:item/ruby_ore")
2600 );
2601 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2602 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2603 assert_eq!(
2604 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2605 json!(3)
2606 );
2607 assert!(
2608 c.outputs
2609 .iter()
2610 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2611 );
2612 let src = c.source.as_ref().unwrap();
2613 assert!(
2614 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2615 "{src}"
2616 );
2617 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2618 assert!(src.contains(".requires_tool()"), "{src}");
2619 assert!(
2620 src.contains("registry.register_block(Blocks::ruby_ore());"),
2621 "{src}"
2622 );
2623
2624 let mut outputs = Vec::new();
2625 merge_outputs(&root, &mut outputs, c).unwrap();
2626 let find = |p: &str| -> Value {
2627 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2628 };
2629 assert_eq!(
2630 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2631 json!("Ruby Ore")
2632 );
2633 assert_eq!(
2634 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2635 json!(["mymod:ruby_ore"])
2636 );
2637 assert_eq!(
2638 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2639 json!(["mymod:ruby_ore"])
2640 );
2641 let _ = std::fs::remove_dir_all(root);
2642 }
2643
2644 #[test]
2645 fn raw_parent_with_elements_gets_a_particle() {
2646 let root = project(&[(
2647 "content/cable.yblock",
2648 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2649 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2650 )]);
2651 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2652 let m = output(&c, "assets/mymod/models/block/cable.json");
2653 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2654 let _ = std::fs::remove_dir_all(root);
2655 }
2656
2657 #[test]
2658 fn compiles_elements_shape_and_connect() {
2659 let root = project(&[(
2660 "content/pipe.yblock",
2661 r##"{
2662 "block": { "render_layer": "cutout" },
2663 "model": {
2664 "textures": { "0": "minecraft:block/stone" },
2665 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2666 "rotation": { "axis": "y", "angle": 22.5 },
2667 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2668 },
2669 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2670 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2671}"##,
2672 )]);
2673 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2674 let m = output(&c, "assets/mymod/models/block/pipe.json");
2675 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2676 assert_eq!(m["render_type"], json!("minecraft:cutout"));
2677 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2678 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2679 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2680 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2681 let src = c.source.unwrap();
2682 assert!(
2683 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2684 "{src}"
2685 );
2686 assert!(src.contains(".connects_to_neighbors()"), "{src}");
2687 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2688 let _ = std::fs::remove_dir_all(root);
2689 }
2690
2691 #[test]
2692 fn reports_errors_with_lines() {
2693 let root = project(&[(
2694 "content/bad.yblock",
2695 "{\n \"model\": {\n \"textures\": { \"0\": \"minecraft:block/stone\" },\n \"elements\": [ {\n \"from\": [0, 0, 0], \"to\": [16, 16, 16],\n \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n \"faces\": { \"north\": { \"texture\": \"#1\" } }\n } ]\n },\n \"bogus\": 1\n}",
2696 )]);
2697 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2698 assert!(
2699 e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2700 "{e}"
2701 );
2702 assert!(
2703 e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2704 "{e}"
2705 );
2706 assert!(
2707 e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2708 "{e}"
2709 );
2710 assert!(
2711 e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2712 "{e}"
2713 );
2714 let _ = std::fs::remove_dir_all(root);
2715 }
2716
2717 #[test]
2718 fn compiles_recipes() {
2719 let root = project(&[
2720 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2721 (
2722 "content/recipes/ruby_block.yrecipe",
2723 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "mymod:ruby" }, "result": "minecraft:emerald_block" }"##,
2724 ),
2725 (
2726 "content/recipes/ruby.yrecipe",
2727 r##"{ "type": "shapeless", "ingredients": ["minecraft:emerald", "#c:dusts"], "result": "mymod:ruby", "count": 2 }"##,
2728 ),
2729 (
2730 "content/recipes/smelt_ruby.yrecipe",
2731 r##"{ "type": "smelting", "ingredient": "#c:ores", "result": "mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
2732 ),
2733 (
2734 "content/recipes/cut.yrecipe",
2735 r##"{ "type": "stonecutting", "ingredient": ["minecraft:stone", "minecraft:andesite"], "result": "mymod:ruby", "group": "rubies" }"##,
2736 ),
2737 ]);
2738 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2739 assert_eq!(c.recipes, 4);
2740 let src = c.source.clone().unwrap();
2741 for want in [
2742 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"#,
2743 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
2744 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
2745 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
2746 r##"group: Some("rubies".into())"##,
2747 ] {
2748 assert!(src.contains(want), "missing {want}\n{src}");
2749 }
2750 let _ = std::fs::remove_dir_all(root);
2751 }
2752
2753 #[test]
2754 fn recipe_mistakes() {
2755 let bad = |recipe: &str| {
2756 let root = project(&[("content/r.yrecipe", recipe)]);
2757 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2758 let _ = std::fs::remove_dir_all(root);
2759 e
2760 };
2761 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "minecraft:stick" }, "result": "minecraft:stick" }"#);
2762 assert!(e.contains("pattern uses 'B'"), "{e}");
2763 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "minecraft:stick" }"#);
2764 assert!(e.contains("write `minecraft:stick`"), "{e}");
2765 let e = bad(r##"{ "type": "smelting", "ingredient": "minecraft:sand", "result": "#c:glass" }"##);
2766 assert!(e.contains("must be an item, not a tag"), "{e}");
2767 let e = bad(r#"{ "type": "shapeless", "ingredients": ["minecraft:sand"], "result": "mymod:nothing" }"#);
2768 assert!(e.contains("no block or item mymod:nothing"), "{e}");
2769 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["minecraft:sand"], "result": "minecraft:glass" }"#);
2770 assert!(e.contains("pattern is for shaped recipes"), "{e}");
2771 }
2772
2773 #[test]
2774 fn block_inventory_names_a_screen() {
2775 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
2776 let root = project(&[
2777 ("content/bench.yblock", block),
2778 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
2779 ]);
2780 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2781 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
2782 let _ = std::fs::remove_dir_all(root);
2783
2784 let root = project(&[("content/bench.yblock", block)]);
2785 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2786 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
2787 let _ = std::fs::remove_dir_all(root);
2788 }
2789
2790 #[test]
2791 fn compiles_a_book() {
2792 let book = r#"{
2793 "id": "guide",
2794 "name": "@Book.title",
2795 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
2796 "entries": [{
2797 "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
2798 "pages": [
2799 { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
2800 { "type": "spotlight", "item": "@item:mymod:ruby" },
2801 { "type": "entity", "entity": "@entity:minecraft:allay" }
2802 ]
2803 }]
2804 }"#;
2805 let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
2806 let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
2807 let mut refs = yres::Refs::default();
2808 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
2809 let out = compile(&root, "mymod", &refs).unwrap();
2810 assert_eq!(out.books, 1);
2811 let src = out.source.unwrap();
2812 assert!(src.contains("registry.register_book_json(\"mymod:guide\""), "{src}");
2813 assert!(src.contains("mymod.book.title") && src.contains("item.mymod.ruby"), "{src}");
2814 assert!(src.contains("not a ref") && src.contains("i18n"), "{src}");
2815 assert!(src.contains(r#"\"item\":\"mymod:ruby\""#) && src.contains(r#"\"category\":\"mymod:basics\""#), "references become ids: {src}");
2816
2817 let bad = r#"{ "id": "x",
2819 "categories": [{ "id": "c", "name": "C" }],
2820 "entries": [{ "id": "e", "name": "E", "category": "c",
2821 "pages": [
2822 { "type": "spotlight", "item": "minecraft:stick" },
2823 { "type": "spotlight", "item": "@item:minecraft:short_grass" },
2824 { "type": "recipe", "recipe": "@recipe:mymod:nope" },
2825 { "type": "entity", "entity": "@entity:create:train" }
2826 ] }] }"#;
2827 let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2828 assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
2829 assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
2830 assert!(e.contains("not in 1.20.1"), "{e}");
2831 assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
2832 assert!(e.contains("[external.create]"), "{e}");
2833
2834 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
2835 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2836 assert!(e.contains("entries[0].pages[0].type: unknown `nope`"), "{e}");
2837 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
2838 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2839 assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
2840 }
2841
2842 #[test]
2843 fn compiles_a_recipe_of_the_mods_own_kind() {
2844 let r = r##"{ "type": "mymod:infusion", "slots": { "core": ["minecraft:diamond"], "ring": [["minecraft:coal", "minecraft:charcoal"], "#c:dusts"] },
2845 "values": { "time": 200 }, "result": "minecraft:emerald", "count": 2 }"##;
2846 let c = compile(&project(&[("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
2847 let src = c.source.unwrap();
2848 assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
2849 assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
2850
2851 let bad = r#"{ "type": "mymod:infusion", "ingredient": "minecraft:stone", "result": "minecraft:emerald" }"#;
2852 let e = compile(&project(&[("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2853 assert!(e.contains("takes \"slots\"") && e.contains("needs \"slots\""), "{e}");
2854 let other = r#"{ "type": "create:mixing", "result": "minecraft:emerald" }"#;
2856 assert!(compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).is_err());
2857 }
2858
2859 #[test]
2860 fn compiles_an_item_with_resource_refs() {
2861 let root = project(&[(
2862 "content/items/ruby.yitem",
2863 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2864 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2865 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2866 )]);
2867 let mut refs = yres::Refs::default();
2868 refs.texts.insert(
2869 "Items.ruby".into(),
2870 ("item.mymod.ruby".into(), "Ruby".into()),
2871 );
2872 refs.texts.insert(
2873 "Strings.ruby_tip".into(),
2874 ("mymod.ruby_tip".into(), "Shiny".into()),
2875 );
2876 refs.images
2877 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2878 let c = compile(&root, "mymod", &refs).unwrap();
2879 assert_eq!(
2880 output(&c, "assets/mymod/models/item/ruby.json"),
2881 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2882 );
2883 let src = c.source.clone().unwrap();
2884 assert!(src.contains(".max_stack(16)"), "{src}");
2885 assert!(
2886 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2887 "{src}"
2888 );
2889 let mut outputs = vec![(
2891 "assets/mymod/lang/ru_ru.json".to_string(),
2892 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2893 .as_bytes()
2894 .to_vec(),
2895 )];
2896 merge_outputs(&root, &mut outputs, c).unwrap();
2897 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2898 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2899 let _ = std::fs::remove_dir_all(root);
2900 }
2901
2902 #[test]
2903 fn namespace_needed_for_odd_mod_ids() {
2904 let root = project(&[(
2905 "content/x.yitem",
2906 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2907 )]);
2908 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2909 assert!(e.contains("give the namespace"), "{e}");
2910 let _ = std::fs::remove_dir_all(root);
2911 }
2912}