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