1mod import;
23pub mod json;
24mod refs;
25mod table;
26
27pub use import::import_command;
28
29use crate::yres;
30use yog_data::Path as At;
31use json::num_value;
32use serde_json::{Map, Value, json};
33use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
34use std::fmt::Write as _;
35use std::path::{Path, PathBuf};
36
37pub const CONTENT_DIR: &str = "content";
39pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
41
42pub type Output = (String, Vec<u8>);
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Kind {
48 Block,
50 Item,
52 Recipe,
54 Book,
56 Def,
59 Model,
61 Anim,
63 Entity,
65}
66
67impl Kind {
68 pub fn of(path: &Path) -> Option<Kind> {
69 match path.extension()?.to_str()? {
70 "yblock" => Some(Kind::Block),
71 "yitem" => Some(Kind::Item),
72 "yrecipe" => Some(Kind::Recipe),
73 "ybook" => Some(Kind::Book),
74 "ydef" => Some(Kind::Def),
75 "ymodel" => Some(Kind::Model),
76 "yanim" => Some(Kind::Anim),
77 "yentity" => Some(Kind::Entity),
78 _ => None,
79 }
80 }
81
82 fn word(self) -> &'static str {
83 match self {
84 Kind::Block => "block",
85 Kind::Item => "item",
86 Kind::Recipe => "recipe",
87 Kind::Book => "book",
88 Kind::Def => "definition",
89 Kind::Model => "model",
90 Kind::Anim => "animation",
91 Kind::Entity => "entity",
92 }
93 }
94}
95
96#[derive(Debug, Default)]
98pub struct Compiled {
99 pub outputs: Vec<Output>,
101 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
104 lang_aliases: Vec<(String, String)>,
106 tags: BTreeMap<String, BTreeMap<String, bool>>,
109 pub source: Option<String>,
111 pub data_files: Vec<(String, Vec<u8>)>,
114 pub warnings: Vec<String>,
115 pub registrations: String,
118 pub blocks: usize,
119 pub items: usize,
120 pub recipes: usize,
121 pub books: usize,
122}
123
124pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
127pub const DISPLAY_SLOTS: [&str; 8] = [
128 "thirdperson_righthand",
129 "thirdperson_lefthand",
130 "firstperson_righthand",
131 "firstperson_lefthand",
132 "gui",
133 "head",
134 "ground",
135 "fixed",
136];
137pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
138pub const RECIPE_TYPES: [&str; 8] =
139 ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
140
141pub struct Template {
143 pub name: &'static str,
144 pub parent: &'static str,
146 pub slots: &'static [&'static str],
148 pub sprite: bool,
150 pub has_particle: bool,
152}
153
154pub const TEMPLATES: &[Template] = &[
155 Template {
156 name: "cube_all",
157 parent: "minecraft:block/cube_all",
158 slots: &["all"],
159 sprite: false,
160 has_particle: true,
161 },
162 Template {
163 name: "cube_column",
164 parent: "minecraft:block/cube_column",
165 slots: &["end", "side"],
166 sprite: false,
167 has_particle: true,
168 },
169 Template {
170 name: "cube_bottom_top",
171 parent: "minecraft:block/cube_bottom_top",
172 slots: &["top", "bottom", "side"],
173 sprite: false,
174 has_particle: true,
175 },
176 Template {
177 name: "cube",
178 parent: "minecraft:block/cube",
179 slots: &["down", "up", "north", "south", "west", "east"],
180 sprite: false,
181 has_particle: false,
182 },
183 Template {
184 name: "orientable",
185 parent: "minecraft:block/orientable",
186 slots: &["front", "side", "top"],
187 sprite: false,
188 has_particle: true,
189 },
190 Template {
191 name: "cross",
192 parent: "minecraft:block/cross",
193 slots: &["cross"],
194 sprite: false,
195 has_particle: true,
196 },
197 Template {
198 name: "generated",
199 parent: "minecraft:item/generated",
200 slots: &["layer0"],
201 sprite: true,
202 has_particle: true,
203 },
204 Template {
205 name: "handheld",
206 parent: "minecraft:item/handheld",
207 slots: &["layer0"],
208 sprite: true,
209 has_particle: true,
210 },
211];
212
213pub fn template(name: &str) -> Option<&'static Template> {
214 TEMPLATES.iter().find(|t| t.name == name)
215}
216
217pub fn valid_namespace(s: &str) -> bool {
219 !s.is_empty()
220 && s.bytes().all(|b| {
221 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
222 })
223}
224
225pub fn valid_path(s: &str) -> bool {
227 !s.is_empty()
228 && !s.starts_with('/')
229 && !s.ends_with('/')
230 && s.bytes().all(|b| {
231 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
232 })
233}
234
235pub fn valid_id(s: &str) -> bool {
237 match s.split_once(':') {
238 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
239 None => valid_path(s),
240 }
241}
242
243pub fn find(root: &Path) -> Vec<PathBuf> {
247 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
248 let Ok(entries) = std::fs::read_dir(dir) else {
249 return;
250 };
251 for entry in entries.flatten() {
252 let path = entry.path();
253 if path.is_dir() {
254 walk(&path, out);
255 } else if Kind::of(&path).is_some() {
256 out.push(path);
257 }
258 }
259 }
260 let mut out = Vec::new();
261 walk(&root.join(CONTENT_DIR), &mut out);
262 out.sort();
263 out
264}
265
266fn display(root: &Path, p: &Path) -> String {
267 p.strip_prefix(root)
268 .unwrap_or(p)
269 .to_string_lossy()
270 .replace('\\', "/")
271}
272
273#[derive(Debug, Clone, Default)]
276struct BlockProps {
277 hardness: Option<f64>,
278 resistance: Option<f64>,
279 sound: Option<String>,
280 light: u8,
281 tools: Vec<String>,
283 tool_level: Option<(String, usize)>,
286 requires_tool: bool,
287 no_collision: bool,
288 slipperiness: Option<f64>,
289 shape: Option<[f64; 6]>,
291 connect_groups: Vec<String>,
292 connects: bool,
293 facing: String,
295 placement: String,
297 attach: bool,
298 waterlogged: bool,
299 properties: Vec<(String, Vec<String>)>,
301 powered: Option<String>,
303 comparator: Option<String>,
304 accepts_items: bool,
306 inventory: Option<String>,
308 redstone: bool,
310}
311
312#[derive(Debug, Clone, Default)]
313struct ItemProps {
314 stack_size: Option<u8>,
315 durability: u32,
316 fire_resistant: bool,
317 fuel_ticks: u32,
318 food: Option<(u32, f64, bool)>,
320 tool: Option<(String, String, f32, f32)>,
322 armor: Option<(String, String)>,
324 wearable: Option<String>,
326}
327
328#[derive(Debug, Clone)]
330struct Entry {
331 kind: Kind,
332 id: String,
333 file: String,
334 auto: bool,
335 requires: Vec<String>,
337 block: BlockProps,
338 item: ItemProps,
339}
340
341struct Ctx<'a> {
343 root: &'a Path,
344 refs: &'a yres::Refs,
345 file: String,
346 ns: String,
347 errors: &'a mut Vec<String>,
348 warnings: &'a mut Vec<String>,
349 textures: &'a mut BTreeMap<String, PathBuf>,
351 inventories: &'a mut Vec<(String, usize, String)>,
354 deferred: &'a mut Vec<(String, usize, String, yog_data::Ref)>,
357}
358
359impl Ctx<'_> {
360 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
361 self.errors.push(format!("{}:{line}: {msg}", self.file));
362 }
363
364 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
365 self.warnings.push(format!("{}:{line}: {msg}", self.file));
366 }
367
368 fn err_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
370 let line = loc(path);
371 self.err(line, format!("{path}: {msg}"));
372 }
373
374 fn warn_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
375 let line = loc(path);
376 self.warn(line, format!("{path}: {msg}"));
377 }
378
379 fn schema_errors(&mut self, errors: yog_data::Errors) {
381 for e in errors.0 {
382 let l = e.at.map_or(1, |a| a.line as usize);
383 if e.path.is_root() {
384 self.err(l, e.message)
385 } else {
386 self.err(l, format!("{}: {}", e.path, e.message))
387 }
388 }
389 }
390
391 fn texture(&mut self, loc: Loc, path: &At, t: &yog_formats::content::Texture) -> Option<String> {
395 let s = match t {
396 yog_formats::content::Texture::Slot(slot) => return Some(format!("#{slot}")),
397 yog_formats::content::Texture::Res(r) => {
398 let name = format!("{}.{}", r.set, r.key);
399 return match self.refs.images.get(&name) {
400 Some(id) => Some(id.clone()),
401 None => {
402 self.err_at(loc, path, format!("`@{name}`: no image `{name}` in resources/*.yres"));
403 None
404 }
405 };
406 }
407 yog_formats::content::Texture::Plain(s) => s.trim().to_string(),
408 };
409 if let Some(stem) = s.strip_suffix(".png") {
410 let rel = stem.trim_start_matches("./").trim_start_matches('/');
411 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
412 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
413 self.err_at(loc, path, format!("`{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"));
414 return None;
415 }
416 let file = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png"));
417 if !file.is_file() {
418 self.err_at(loc, path, format!("resources/{rel}.png doesn't exist"));
419 return None;
420 }
421 let target = format!("assets/{}/textures/{rel}.png", self.ns);
422 if let Some(other) = self.textures.insert(target.clone(), file.clone())
423 && other != file
424 {
425 let shown = display(self.root, &other);
426 self.err_at(loc, path, format!("{target} also comes from {shown}"));
427 }
428 let meta = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png.mcmeta"));
429 if meta.is_file() {
430 self.textures.insert(format!("{target}.mcmeta"), meta);
431 }
432 return Some(format!("{}:{rel}", self.ns));
433 }
434 if !valid_id(&s) {
435 self.err_at(loc, path, format!("`{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"));
436 return None;
437 }
438 let id = if s.contains(':') { s } else { format!("minecraft:{s}") };
439 let (ns, tex) = id.split_once(':').unwrap_or_default();
440 if ns == self.ns {
441 let file = self.root.join("assets").join(ns).join("textures").join(format!("{tex}.png"));
442 let bundled = self.textures.contains_key(&format!("assets/{ns}/textures/{tex}.png")) || self.refs.images.values().any(|v| *v == id);
443 if !file.is_file() && !bundled {
444 self.warn_at(loc, path, format!("texture {id} isn't in assets/{ns}/textures/ or resources/"));
445 }
446 }
447 Some(id)
448 }
449}
450
451type Loc<'a> = &'a dyn Fn(&At) -> usize;
453
454struct Model {
456 json: Value,
457 bounds: Option<[f64; 6]>,
459}
460
461fn nums(ns: &[f64]) -> Value {
462 Value::Array(ns.iter().map(|n| num_value(*n)).collect())
463}
464
465fn model(cx: &mut Ctx, loc: Loc, at: &At, m: &yog_formats::content::Model, item: bool) -> Option<Model> {
467 let mut out = Map::new();
468 let mut tpl: Option<&Template> = None;
469 let mut raw_parent = false;
470 if let Some(name) = &m.parent {
471 if let Some(t) = template(name) {
472 if t.sprite && !item {
473 cx.err_at(loc, &at.key("parent"), format!("{name:?} is an item sprite — block models use cube_all, cube_column, ..."));
474 }
475 out.insert("parent".into(), Value::String(t.parent.into()));
476 tpl = Some(t);
477 } else if name.contains(':') && valid_id(name) {
478 out.insert("parent".into(), Value::String(name.clone()));
479 raw_parent = true;
480 } else {
481 let names: Vec<&str> = TEMPLATES.iter().filter(|t| item || !t.sprite).map(|t| t.name).collect();
482 cx.err_at(loc, &at.key("parent"), format!("{name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")));
483 return None;
484 }
485 }
486
487 let mut slots: Vec<String> = Vec::new();
489 let mut textures = Map::new();
490 for (slot, tex) in &m.textures.0 {
491 let here = at.key("textures").key(slot);
492 if slot.is_empty() || slot.starts_with('#') {
493 cx.err_at(loc, &here, "slot names are plain words (`all`, `side`, `0`)");
494 continue;
495 }
496 if let Some(t) = cx.texture(loc, &here, tex) {
497 textures.insert(slot.clone(), Value::String(t));
498 slots.push(slot.clone());
499 }
500 }
501 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
502 if let Some(t) = tpl {
503 if t.sprite {
504 if !defined("layer0", &textures) {
505 cx.err_at(loc, at, format!("parent {:?} needs textures.layer0", t.name));
506 }
507 for slot in &slots {
508 let n = slot.strip_prefix("layer").and_then(|n| n.parse::<usize>().ok());
509 match n {
510 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
511 Some(_) => cx.err_at(loc, &at.key("textures").key(slot), "without the layer before it"),
512 None if slot == "particle" => {}
513 None => cx.warn_at(loc, &at.key("textures").key(slot), "isn't used by an item sprite (layer0, layer1, ...)"),
514 }
515 }
516 } else {
517 for slot in t.slots {
518 if !defined(slot, &textures) {
519 cx.err_at(loc, at, format!("parent {:?} needs textures.{slot}", t.name));
520 }
521 }
522 }
523 }
524
525 let mut bounds: Option<[f64; 6]> = None;
527 if let Some(els) = &m.elements {
528 if let Some(t) = tpl {
529 cx.err_at(loc, &at.key("elements"), format!("elements replace the shape of parent {:?} — remove one of them", t.name));
530 }
531 let mut elements = Vec::new();
532 for (i, el) in els.iter().enumerate() {
533 if let Some(json) = element(cx, loc, &at.key("elements").index(i), el, &textures, raw_parent) {
534 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
535 for a in 0..3 {
536 b[a] = b[a].min(el.from[a].min(el.to[a]));
537 b[a + 3] = b[a + 3].max(el.from[a].max(el.to[a]));
538 }
539 elements.push(json);
540 }
541 }
542 out.insert("elements".into(), Value::Array(elements));
543 if m.parent.is_none() {
546 out.insert("parent".into(), Value::String("minecraft:block/block".into()));
547 }
548 } else if tpl.is_none() && !raw_parent {
549 cx.err_at(loc, at, "no parent and no elements — give a template (parent: \"cube_all\") or elements");
550 }
551
552 if !textures.contains_key("particle")
556 && !tpl.is_some_and(|t| t.has_particle)
557 && (!raw_parent || m.elements.is_some())
558 && let Some(first) = slots.first()
559 {
560 let v = textures[first].clone();
561 textures.insert("particle".into(), v);
562 }
563 if !textures.is_empty() {
564 out.insert("textures".into(), Value::Object(textures));
565 }
566 if let Some(d) = &m.display {
567 let mut display = Map::new();
568 for (place, t) in d.iter() {
569 let mut o = Map::new();
570 for (key, v) in [("rotation", t.rotation), ("translation", t.translation), ("scale", t.scale)] {
571 if let Some(v) = v {
572 o.insert(key.into(), nums(&v));
573 }
574 }
575 display.insert(place.into(), Value::Object(o));
576 }
577 out.insert("display".into(), Value::Object(display));
578 }
579 if let Some(b) = m.ambient_occlusion {
580 out.insert("ambientocclusion".into(), Value::Bool(b));
581 }
582 if let Some(g) = &m.gui_light {
583 out.insert("gui_light".into(), Value::String(yog_formats::content::word(g)));
584 }
585 Some(Model { json: Value::Object(out), bounds })
586}
587
588fn element(cx: &mut Ctx, loc: Loc, at: &At, el: &yog_formats::content::Element, textures: &Map<String, Value>, raw_parent: bool) -> Option<Value> {
590 let mut out = Map::new();
591 out.insert("from".into(), nums(&el.from));
592 out.insert("to".into(), nums(&el.to));
593 if let Some(r) = &el.rotation {
594 if (r.angle / 22.5).fract() != 0.0 {
595 cx.err_at(loc, &at.key("rotation").key("angle"), format!("{} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees", r.angle));
596 }
597 let mut rot = Map::new();
598 rot.insert("origin".into(), nums(&r.origin.unwrap_or([8.0, 8.0, 8.0])));
599 rot.insert("axis".into(), Value::String(yog_formats::content::word(&r.axis)));
600 rot.insert("angle".into(), num_value(r.angle));
601 if r.rescale {
602 rot.insert("rescale".into(), Value::Bool(true));
603 }
604 out.insert("rotation".into(), Value::Object(rot));
605 }
606 if el.shade == Some(false) {
607 out.insert("shade".into(), Value::Bool(false));
608 }
609 let mut faces = Map::new();
610 match &el.faces {
611 None => cx.warn_at(loc, at, "no faces — it's invisible"),
612 Some(f) => {
613 for (dir, face) in f.iter() {
614 let here = at.key("faces").key(dir);
615 let mut fo = Map::new();
616 let slot = face.texture.strip_prefix('#').unwrap_or(&face.texture);
617 if !textures.contains_key(slot) && !raw_parent {
618 cx.err_at(loc, &here.key("texture"), format!("`#{slot}`: no such slot in textures"));
619 }
620 fo.insert("texture".into(), Value::String(format!("#{slot}")));
621 if let Some(uv) = face.uv {
622 fo.insert("uv".into(), nums(&uv));
623 }
624 match face.rotation {
625 None | Some(0) => {}
626 Some(n @ (90 | 180 | 270)) => {
627 fo.insert("rotation".into(), Value::from(n));
628 }
629 Some(_) => cx.err_at(loc, &here.key("rotation"), "must be 0, 90, 180 or 270"),
630 }
631 if let Some(c) = &face.cullface {
632 fo.insert("cullface".into(), Value::String(yog_formats::content::word(c)));
633 }
634 if let Some(t) = face.tint
635 && t >= 0
636 {
637 fo.insert("tintindex".into(), Value::from(t));
638 }
639 faces.insert(dir.into(), Value::Object(fo));
640 }
641 }
642 }
643 out.insert("faces".into(), Value::Object(faces));
644 Some(Value::Object(out))
645}
646
647fn file_id(cx: &mut Ctx, line: usize, id: Option<&str>, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
649 let raw = id.map(str::to_string).unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
650 let (ns, p) = match raw.split_once(':') {
651 Some((ns, p)) => (ns.to_string(), p.to_string()),
652 None => match mod_ns {
653 Some(ns) => (ns.to_string(), raw.clone()),
654 None => {
655 cx.err(line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: id: \"mynamespace:name\"");
656 return None;
657 }
658 },
659 };
660 if !valid_namespace(&ns) || !valid_path(&p) {
661 cx.err(line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
662 return None;
663 }
664 Some((ns, p))
665}
666
667fn turn(facing: Option<&str>, face: Option<&str>) -> (f64, f64) {
672 let yaw = |f: &str| match f {
673 "east" => 90.0,
674 "south" => 180.0,
675 "west" => 270.0,
676 _ => 0.0,
677 };
678 match (face, facing) {
679 (Some("wall"), Some(f)) => (90.0, yaw(f)),
680 (Some("ceiling"), Some(f)) => (180.0, (yaw(f) + 180.0) % 360.0),
681 (_, Some("up")) => (270.0, 0.0),
682 (_, Some("down")) => (90.0, 0.0),
683 (_, Some(f)) => (0.0, yaw(f)),
684 _ => (0.0, 0.0),
685 }
686}
687
688fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
689 let mut v = Map::new();
690 v.insert("model".into(), Value::String(model.into()));
691 if x != 0.0 {
692 v.insert("x".into(), num_value(x));
693 }
694 if y != 0.0 {
695 v.insert("y".into(), num_value(y));
696 }
697 if uvlock {
698 v.insert("uvlock".into(), Value::Bool(true));
699 }
700 if let Some(w) = weight {
701 v.insert("weight".into(), num_value(w));
702 }
703 Value::Object(v)
704}
705
706fn pretty(v: &Value) -> Vec<u8> {
707 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
708 s.push('\n');
709 s.into_bytes()
710}
711
712fn compile_file(
715 cx: &mut Ctx,
716 text: &str,
717 kind: Kind,
718 path: &Path,
719 mod_ns: Option<&str>,
720 out: &mut Compiled,
721 generated: &mut BTreeMap<String, String>,
722) -> Option<Entry> {
723 let doc = match yog_data::text::parse(text) {
724 Ok(d) => d,
725 Err(e) => {
726 cx.err(e.at.line as usize, e.message);
727 return None;
728 }
729 };
730 let loc_fn = |p: &At| doc.locate(text, p).line as usize;
731 let loc: Loc = &loc_fn;
732 let root = At::root();
733 struct Parts {
734 format: Option<u32>,
735 id: Option<String>,
736 name: Option<yog_data::Text>,
737 tooltip: Option<yog_data::Text>,
738 register: Option<yog_formats::content::Register>,
739 model: yog_formats::content::Model,
740 item_model: Option<yog_formats::content::Model>,
741 block: Option<yog_formats::content::BlockProps>,
742 states: Option<yog_formats::content::States>,
743 item: Option<yog_formats::content::ItemProps>,
744 block_item: Option<yog_formats::content::BlockItemProps>,
746 requires: Vec<String>,
747 tags: Vec<String>,
748 }
749 let (parts, refs, tag_refs) = match kind {
750 Kind::Item => match yog_data::schema::from_doc::<yog_formats::content::ItemFile>(text, &doc) {
751 Ok(r) => {
752 let f = r.value;
753 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: f.model, item_model: None, block: None, states: None, item: f.item, block_item: None, requires: f.requires, tags: f.tags }, r.refs, r.tags)
754 }
755 Err(e) => {
756 cx.schema_errors(e);
757 return None;
758 }
759 },
760 Kind::Block => match yog_data::schema::from_doc::<yog_formats::content::BlockFile>(text, &doc) {
761 Ok(r) => {
762 let f = r.value;
763 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: f.model, item_model: f.item_model, block: f.block, states: f.states, item: None, block_item: f.item, requires: f.requires, tags: f.tags }, r.refs, r.tags)
764 }
765 Err(e) => {
766 cx.schema_errors(e);
767 return None;
768 }
769 },
770 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
771 };
772 for f in &refs {
774 cx.deferred.push((cx.file.clone(), loc(&f.path), f.path.to_string(), f.value.clone()));
775 }
776 for f in &tag_refs {
777 cx.deferred.push((cx.file.clone(), loc(&f.path), f.path.to_string(), yog_data::Ref::new(format!("{TAG_REF}{}", f.value.kind), f.value.id.clone())));
778 }
779 for (i, tag) in parts.tags.iter().enumerate() {
781 let at = root.key("tags").index(i);
782 let of = if kind == Kind::Block { "block|item" } else { "item" };
783 cx.deferred.push((cx.file.clone(), loc(&at), at.to_string(), yog_data::Ref::new(format!("{TAG_REF}{of}"), full_id(tag.trim_start_matches('#'), mod_ns.unwrap_or("minecraft")))));
784 }
785 if let Some(f) = parts.format
786 && f != 1
787 {
788 cx.err_at(loc, &root.key("format"), "format must be 1 (this yog understands format 1)");
789 }
790 let (ns, p) = file_id(cx, loc(&root.key("id")), parts.id.as_deref(), path, mod_ns)?;
791 cx.ns = ns.clone();
792 let id = format!("{ns}:{p}");
793 let auto = parts.register.unwrap_or_default() == yog_formats::content::Register::Auto;
794
795 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
797 for (field, text, target) in [("name", &parts.name, key.clone()), ("tooltip", &parts.tooltip, format!("{key}.desc"))] {
798 let Some(t) = text else { continue };
799 let from = match t {
800 yog_data::Text::Literal(s) => {
801 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(target, s.clone());
802 continue;
803 }
804 yog_data::Text::Lang(k) => k.clone(),
805 yog_data::Text::NameOf(r) => {
806 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
807 format!("{}.{rns}.{}", r.kind, rp.replace('/', "."))
808 }
809 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
810 Some((k, _)) => k.clone(),
811 None => {
812 cx.err_at(loc, &root.key(field), format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key));
813 continue;
814 }
815 },
816 };
817 if from != target {
818 out.lang_aliases.push((from, target));
819 }
820 }
821
822 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
823 let full = format!("assets/{ns}/{rel}");
824 if cx.root.join(&full).is_file() {
825 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
826 }
827 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
828 cx.err(1, format!("{full} is also written by {other}"));
829 }
830 out.outputs.push((full, pretty(value)));
831 };
832
833 let mut entry = Entry { kind, id: id.clone(), file: cx.file.clone(), auto, requires: parts.requires.clone(), block: BlockProps::default(), item: ItemProps::default() };
834
835 for tag in &parts.tags {
837 let tag = full_id(tag.trim_start_matches('#'), &cx.ns);
838 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", &tag));
839 if kind == Kind::Block {
840 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone(), true);
841 }
842 out.tags.entry(format!("data/{tns}/tags/item/{tpath}.json")).or_default().insert(id.clone(), true);
843 }
844
845 if let Some(it) = &parts.block_item {
847 entry.item.stack_size = it.stack_size;
848 entry.item.fire_resistant = it.fire_resistant;
849 entry.item.fuel_ticks = it.fuel_ticks.unwrap_or(0);
850 }
851
852 if kind == Kind::Item {
853 if let Some(it) = &parts.item {
854 let props = &mut entry.item;
855 props.stack_size = it.stack_size;
856 props.durability = it.durability.unwrap_or(0);
857 props.fire_resistant = it.fire_resistant;
858 props.fuel_ticks = it.fuel_ticks.unwrap_or(0);
859 props.food = it.food.as_ref().map(|f| (f.nutrition, f.saturation, f.always_edible));
860 props.tool = it.tool.as_ref().map(|t| {
861 let (d, s) = t.attack();
862 (t.kind.clone(), t.material.clone(), d, s)
863 });
864 props.armor = it.armor.as_ref().map(|a| (yog_formats::content::word(&a.slot), a.material.clone()));
865 props.wearable = it.wearable.as_ref().map(yog_formats::content::word);
866 if props.wearable.is_some() && props.armor.is_some() {
867 cx.err_at(loc, &root.key("item").key("wearable"), "armor is worn already — leave wearable out");
868 }
869 if props.tool.is_some() && props.armor.is_some() {
870 cx.err_at(loc, &root.key("item").key("armor"), "an item is a tool or armor, not both");
871 }
872 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
873 cx.warn_at(loc, &root.key("item").key("stack_size"), "ignored: an item with durability stacks to 1");
874 }
875 }
876 let m = model(cx, loc, &root.key("model"), &parts.model, true)?;
877 emit(cx, format!("models/item/{p}.json"), &m.json);
878 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
879 return Some(entry);
880 }
881
882 let b = parts.block.clone().unwrap_or_default();
884 let bat = root.key("block");
885 let props = &mut entry.block;
886 props.hardness = b.hardness;
887 props.resistance = b.resistance;
888 props.sound = b.sound.as_ref().map(yog_formats::content::word);
889 props.light = b.light.unwrap_or(0);
890 props.tools = b.tool.iter().map(|t| t.0.clone()).collect();
891 props.tool_level = b.tool_level.clone().map(|l| (l, loc(&bat.key("tool_level"))));
892 props.requires_tool = b.requires_tool;
893 props.no_collision = b.collision == Some(false);
894 props.slipperiness = b.slipperiness;
895 props.redstone = b.redstone;
896 props.accepts_items = b.accepts_items;
897 props.connect_groups = b.connect_groups.clone();
898 let shape_auto = matches!(b.shape, None | Some(yog_formats::content::BlockShape::Auto));
899 if let Some(yog_formats::content::BlockShape::Box(bx)) = b.shape {
900 props.shape = Some(bx);
901 }
902 if let Some(inv) = &b.inventory {
903 if valid_id(inv) && inv.contains(':') {
904 props.inventory = Some(inv.clone());
905 let at = (cx.file.clone(), loc(&bat.key("inventory")), inv.clone());
906 cx.inventories.push(at);
907 } else {
908 cx.err_at(loc, &bat.key("inventory"), format!("`{inv}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
909 }
910 }
911 if props.tool_level.is_some() && props.tools.is_empty() {
912 cx.warn_at(loc, &bat, "tool_level without tool — any tool of that level mines it");
913 }
914 let drops = match &b.drops {
915 None | Some(yog_formats::content::Drops::Itself) => drops_table(&id, None),
916 Some(yog_formats::content::Drops::Nothing) => Value::Null,
917 Some(yog_formats::content::Drops::Item(d)) => drops_table(d.item.as_deref().unwrap_or(&id), d.count),
918 };
919 let render_layer = b.render_layer.as_ref().map(yog_formats::content::word);
920
921 let mut m = model(cx, loc, &root.key("model"), &parts.model, false)?;
922 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
923 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
925 }
926 if shape_auto {
927 entry.block.shape = m
928 .bounds
929 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
930 .map(|b| [b[0].max(0.0), b[1].max(0.0), b[2].max(0.0), b[3].min(16.0), b[4].min(16.0), b[5].min(16.0)]);
931 }
932 let main = format!("{ns}:block/{p}");
933 emit(cx, format!("models/block/{p}.json"), &m.json);
934
935 let mut rotations: Vec<Value> = Vec::new();
937 let mut arm: Option<(String, bool)> = None;
938 if let Some(s) = &parts.states {
939 let sat = root.key("states");
940 if let Some(rs) = &s.rotations {
941 if rs.is_empty() {
942 cx.err_at(loc, &sat.key("rotations"), "a non-empty list of { x: 0, y: 90, weight: 1, uvlock: false }");
943 }
944 for (i, r) in rs.iter().enumerate() {
945 let here = sat.key("rotations").index(i);
946 let mut angle = |k: &str, v: Option<u32>| match v {
947 None => 0.0,
948 Some(n @ (0 | 90 | 180 | 270)) => n as f64,
949 Some(_) => {
950 cx.err_at(loc, &here.key(k), "must be 0, 90, 180 or 270");
951 0.0
952 }
953 };
954 let (x, y) = (angle("x", r.x), angle("y", r.y));
955 rotations.push(variant(&main, x, y, r.uvlock, r.weight));
956 }
957 }
958 if let Some(c) = &s.connect {
959 if let Some(am) = model(cx, loc, &sat.key("connect").key("arm"), &c.arm, false) {
960 let mut json = am.json;
961 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json) {
962 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
963 }
964 emit(cx, format!("models/block/{p}_arm.json"), &json);
965 arm = Some((format!("{ns}:block/{p}_arm"), c.uvlock));
966 }
967 entry.block.connects = true;
968 if entry.block.connect_groups.is_empty() {
969 entry.block.connect_groups.push(id.clone());
970 }
971 }
972 if s.facing.is_some() || s.attach || s.waterlogged || !s.properties.0.is_empty() {
973 if s.rotations.is_some() || s.connect.is_some() {
974 cx.err_at(loc, &sat, "facing, attach, waterlogged and properties can't go with rotations or connect");
975 }
976 let b = &mut entry.block;
977 use yog_formats::content::{Facing, Placement};
978 if s.attach {
979 if s.facing == Some(Facing::All) {
980 cx.err_at(loc, &sat.key("facing"), "an attached block turns four ways — leave facing out");
981 }
982 b.attach = true;
983 b.facing = "horizontal".into();
984 } else if let Some(f) = s.facing {
985 b.facing = if f == Facing::All { "all" } else { "horizontal" }.into();
986 }
987 if !b.facing.is_empty() {
988 b.placement = if s.placement == Some(Placement::Away) { "away" } else { "toward" }.into();
989 } else if s.placement.is_some() {
990 cx.err_at(loc, &sat.key("placement"), "placement needs facing (or attach)");
991 }
992 b.waterlogged = s.waterlogged;
993 for (name, values) in &s.properties.0 {
994 let here = sat.key("properties").key(name);
995 let word = |w: &str| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
996 if !word(name) {
997 cx.err_at(loc, &here, format!("`{name}`: a property's name is lowercase letters, digits and _"));
998 } else if ["facing", "face", "waterlogged"].contains(&name.as_str()) {
999 cx.err_at(loc, &here, format!("`{name}` comes from facing, attach or waterlogged — name yours otherwise"));
1000 }
1001 let mut seen = std::collections::HashSet::new();
1002 if values.len() < 2 || !values.iter().all(|v| word(v) && seen.insert(v.as_str())) {
1003 cx.err_at(loc, &here, "two or more different values, lowercase letters, digits and _");
1004 }
1005 b.properties.push((name.clone(), values.clone()));
1006 }
1007 } else if s.placement.is_some() || !s.models.0.is_empty() || !s.variants.0.is_empty() {
1008 cx.err_at(loc, &sat, "placement, models and variants need facing, attach, waterlogged or properties");
1009 }
1010 }
1011 let states: Vec<(String, Vec<String>)> = {
1014 let b = &entry.block;
1015 let words = |ws: &[&str]| ws.iter().map(|w| w.to_string()).collect::<Vec<_>>();
1016 let mut all = Vec::new();
1017 match b.facing.as_str() {
1018 "all" => all.push(("facing".to_string(), words(&["north", "east", "south", "west", "up", "down"]))),
1019 "horizontal" => all.push(("facing".to_string(), words(&["north", "east", "south", "west"]))),
1020 _ => {}
1021 }
1022 if b.attach {
1023 all.push(("face".to_string(), words(&["floor", "wall", "ceiling"])));
1024 }
1025 if b.waterlogged {
1026 all.push(("waterlogged".to_string(), words(&["false", "true"])));
1027 }
1028 all.extend(b.properties.iter().cloned());
1029 all.sort_by(|a, b| a.0.cmp(&b.0));
1030 all
1031 };
1032 let yes_no = |name: &str| states.iter().any(|(n, v)| n == name && *v == ["false", "true"]);
1034 for (key, value) in [("powered", &b.powered), ("comparator", &b.comparator)] {
1035 if let Some(name) = value {
1036 if yes_no(name) {
1037 if key == "powered" {
1038 entry.block.powered = Some(name.clone());
1039 } else {
1040 entry.block.comparator = Some(name.clone());
1041 }
1042 } else {
1043 cx.err_at(loc, &bat.key(key), format!("`{name}`: a yes/no property of states.properties ([\"false\", \"true\"])"));
1044 }
1045 }
1046 }
1047 let mut rules: Vec<(Vec<(String, String)>, Vec<String>)> = Vec::new();
1049 if let Some(s) = &parts.states {
1050 let sat = root.key("states");
1051 let mut named = std::collections::HashMap::new();
1052 for (name, m) in &s.models.0 {
1053 if let Some(mut m) = model(cx, loc, &sat.key("models").key(name), m, false) {
1054 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1055 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
1056 }
1057 emit(cx, format!("models/block/{p}/{name}.json"), &m.json);
1058 named.insert(name.clone(), format!("{ns}:block/{p}/{name}"));
1059 }
1060 }
1061 for (key, names) in &s.variants.0 {
1062 let here = sat.key("variants").key(key);
1063 let mut when = Vec::new();
1064 for pair in key.split(',').filter(|p| !p.is_empty()) {
1065 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1066 if states.iter().any(|(n, vs)| n == k && vs.iter().any(|x| x == v)) {
1067 when.push((k.to_string(), v.to_string()));
1068 } else {
1069 cx.err_at(loc, &here, format!("`{pair}`: no such property and value"));
1070 }
1071 }
1072 let mut models = Vec::new();
1073 for name in &names.0 {
1074 match named.get(name) {
1075 Some(m) => models.push(m.clone()),
1076 None => cx.err_at(loc, &here, format!("`{name}`: not in states.models")),
1077 }
1078 }
1079 if names.0.is_empty() {
1080 cx.err_at(loc, &here, "a model name, or a list of them");
1081 } else if !models.is_empty() {
1082 rules.push((when, models));
1083 }
1084 }
1085 }
1086 let base: Value = match rotations.len() {
1087 0 => variant(&main, 0.0, 0.0, false, None),
1088 1 => rotations.remove(0),
1089 _ => Value::Array(rotations),
1090 };
1091 let uvlock = parts.states.as_ref().is_some_and(|s| s.uvlock);
1092 let blockstate = match &arm {
1093 None if !states.is_empty() => {
1095 let mut variants = Map::new();
1096 let mut combo: Vec<usize> = vec![0; states.len()];
1097 loop {
1098 let at: Vec<(&str, &str)> = states.iter().zip(&combo).map(|((n, vs), &i)| (n.as_str(), vs[i].as_str())).collect();
1099 let value = |k: &str| at.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
1100 let models = rules
1101 .iter()
1102 .find(|(when, _)| when.iter().all(|(k, v)| value(k) == Some(v.as_str())))
1103 .map_or(vec![main.clone()], |(_, m)| m.clone());
1104 let (x, y) = turn(value("facing"), value("face"));
1105 let key: Vec<String> = at.iter().map(|(n, v)| format!("{n}={v}")).collect();
1106 let picks: Vec<Value> = models.iter().map(|m| variant(m, x, y, uvlock, None)).collect();
1107 let v = if picks.len() == 1 { picks.into_iter().next().unwrap() } else { Value::Array(picks) };
1108 variants.insert(key.join(","), v);
1109 let mut i = states.len();
1111 loop {
1112 if i == 0 {
1113 break;
1114 }
1115 i -= 1;
1116 combo[i] += 1;
1117 if combo[i] < states[i].1.len() {
1118 break;
1119 }
1120 combo[i] = 0;
1121 }
1122 if combo.iter().all(|&c| c == 0) {
1123 break;
1124 }
1125 }
1126 json!({ "variants": variants })
1127 }
1128 None => json!({ "variants": { "": base } }),
1129 Some((arm, uvlock)) => {
1130 let mut parts = vec![json!({ "apply": base })];
1131 for (dir, x, y) in [("north", 0.0, 0.0), ("east", 0.0, 90.0), ("south", 0.0, 180.0), ("west", 0.0, 270.0), ("up", 270.0, 0.0), ("down", 90.0, 0.0)] {
1132 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1133 }
1134 json!({ "multipart": parts })
1135 }
1136 };
1137 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1138
1139 let item_model = match &parts.item_model {
1141 None => Some(json!({ "parent": main })),
1142 Some(im) => model(cx, loc, &root.key("item_model"), im, true).map(|m| m.json),
1143 };
1144 if let Some(im) = item_model {
1145 emit(cx, format!("models/item/{p}.json"), &im);
1146 }
1147 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
1148
1149 if !drops.is_null() {
1150 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1151 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1152 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1153 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set drops: \"none\""));
1154 }
1155 out.outputs.push((full, pretty(&drops)));
1156 }
1157 for tag in &entry.block.tools {
1160 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", tag));
1161 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone(), true);
1162 }
1163 Some(entry)
1164}
1165
1166fn drops_table(item: &str, count: Option<yog_formats::content::DropCount>) -> Value {
1168 let mut entry = json!({ "type": "minecraft:item", "name": item });
1169 if let Some(c) = count {
1170 let count = match c {
1171 yog_formats::content::DropCount::Exactly(n) => num_value(n),
1172 yog_formats::content::DropCount::Between(min, max) => json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) }),
1173 };
1174 entry["functions"] = json!([{ "function": "minecraft:set_count", "count": count }]);
1175 }
1176 json!({
1177 "type": "minecraft:block",
1178 "pools": [{
1179 "rolls": 1,
1180 "entries": [entry],
1181 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1182 }]
1183 })
1184}
1185
1186#[derive(Debug, Clone)]
1190enum RecipeKind {
1191 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1194 Shapeless(Vec<Vec<String>>),
1196 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1198 Stonecutting(Vec<String>),
1199 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1200 Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
1204}
1205
1206struct BookFile {
1208 id: String,
1209 data: Vec<u8>,
1212}
1213
1214struct EntityFile {
1216 id: String,
1217 file: String,
1218 model: Option<String>,
1220 animations: Option<String>,
1221 data: Vec<u8>,
1223}
1224
1225impl EntityFile {
1226 fn path(&self) -> String {
1227 format!("entities/{}.ydata", self.id.replace([':', '/'], "__"))
1228 }
1229}
1230
1231fn compile_entity(cx: &mut Ctx, text: &str, path: &Path, mod_ns: Option<&str>, out: &mut Compiled) -> Option<EntityFile> {
1234 use yog_data::Value;
1235 let mut value = match yog_data::text::parse_value(text) {
1236 Ok(v) => v,
1237 Err(e) => {
1238 cx.err(e.at.line as usize, e.message);
1239 return None;
1240 }
1241 };
1242 let Value::Map(map) = &mut value else {
1243 cx.err(1, "an entity is an object: { \"kind\": \"mob\", \"width\": 0.6, \"height\": 1.8, … }");
1244 return None;
1245 };
1246 let given = map.get("id").and_then(Value::as_str).map(str::to_owned);
1247 let (ns, p) = file_id(cx, 1, given.as_deref(), path, mod_ns)?;
1248 let id = format!("{ns}:{p}");
1249 map.insert("id", Value::from(id.as_str()));
1250 if let Some(name) = map.remove("name") {
1252 let key = format!("entity.{ns}.{}", p.replace('/', "."));
1253 match yog_data::from_value::<yog_data::Text>(&name) {
1254 Ok(read) => match read.value {
1255 yog_data::Text::Literal(s) => {
1256 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(key, s);
1257 }
1258 yog_data::Text::Lang(k) => out.lang_aliases.push((k, key)),
1259 yog_data::Text::NameOf(r) => {
1260 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
1261 out.lang_aliases.push((format!("{}.{rns}.{}", r.kind, rp.replace('/', ".")), key));
1262 }
1263 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
1264 Some((k, _)) => out.lang_aliases.push((k.clone(), key)),
1265 None => cx.err(1, format!("name `@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1266 },
1267 },
1268 Err(problems) => {
1269 for pr in problems {
1270 cx.err(1, format!("name: {}", pr.message));
1271 }
1272 }
1273 }
1274 }
1275 let def = match yog_data::from_value::<yog_registry::EntityDef>(&value) {
1276 Ok(read) => read.value,
1277 Err(problems) => {
1278 for pr in problems {
1279 cx.err(1, format!("{}: {}", pr.path, pr.message));
1280 }
1281 return None;
1282 }
1283 };
1284 let qualify = |s: &Option<String>| s.as_ref().map(|s| if s.contains(':') { s.clone() } else { format!("{ns}:{s}") });
1285 let mut def = def;
1286 def.model = qualify(&def.model);
1287 def.animations = qualify(&def.animations);
1288 Some(EntityFile {
1289 id,
1290 file: cx.file.clone(),
1291 model: def.model.clone(),
1292 animations: def.animations.clone(),
1293 data: yog_data::binary::encode(&yog_data::Schema::write(&def)),
1294 })
1295}
1296
1297struct BodyFile {
1299 model: bool,
1300 id: String,
1302 data: Vec<u8>,
1304}
1305
1306impl BodyFile {
1307 fn path(&self) -> String {
1309 let dir = if self.model { "models" } else { "anims" };
1310 format!("body/{dir}/{}.ydata", self.id.replace([':', '/'], "__"))
1311 }
1312}
1313
1314fn compile_body(shown: &str, text: &str, kind: Kind, root: &Path, path: &Path, ns: &str, errors: &mut Vec<String>) -> Option<BodyFile> {
1317 let rel = path.strip_prefix(root.join(CONTENT_DIR)).unwrap_or(path).with_extension("");
1318 let id = format!("{ns}:{}", rel.to_string_lossy().replace('\\', "/"));
1319 let report = |errors: &mut Vec<String>, e: yog_data::Errors| {
1320 for l in e.0 {
1321 errors.push(format!("{shown}:{l}"));
1322 }
1323 };
1324 let value = if kind == Kind::Model {
1325 let file = match yog_data::from_text::<yog_body::format::ModelFile>(text) {
1326 Ok(r) => r.value,
1327 Err(e) => {
1328 report(errors, e);
1329 return None;
1330 }
1331 };
1332 if let Err(e) = yog_body::Model::new(&file, ns) {
1333 errors.push(format!("{shown}: {e}"));
1334 return None;
1335 }
1336 yog_data::Schema::write(&file)
1337 } else {
1338 let file = match yog_data::from_text::<yog_body::format::AnimFile>(text) {
1339 Ok(r) => r.value,
1340 Err(e) => {
1341 report(errors, e);
1342 return None;
1343 }
1344 };
1345 if let Err(e) = yog_body::anim::Clips::new(&file) {
1346 errors.push(format!("{shown}: {e}"));
1347 return None;
1348 }
1349 yog_data::Schema::write(&file)
1350 };
1351 Some(BodyFile { model: kind == Kind::Model, id, data: yog_data::binary::encode(&value) })
1352}
1353
1354fn dimension_path(id: &str) -> String {
1356 format!("dimensions/{}.ydata", id.replace(':', "__"))
1357}
1358
1359impl BookFile {
1360 fn path(&self) -> String {
1362 format!("books/{}.ydata", self.id.replace(':', "__"))
1363 }
1364}
1365
1366fn compile_book(
1375 cx: &mut Ctx,
1376 text: &str,
1377 mod_ns: Option<&str>,
1378 known: &refs::Known,
1379 layouts: &[yog_book::layout::RecipeLayout],
1380 recipe_kinds: &HashMap<String, String>,
1381 page_types: &[yog_book::page_type::PageType],
1382) -> Option<BookFile> {
1383 use refs::RefKind;
1384 use yog_data::schema::{Resolve, Text};
1385 use yog_data::{Path, Ref, Res};
1386
1387 struct Check<'a> {
1388 known: &'a refs::Known,
1389 texts: &'a HashMap<String, (String, String)>,
1390 entries: HashSet<String>,
1391 categories: HashSet<String>,
1392 text_refs: bool,
1393 }
1394
1395 impl Resolve for Check<'_> {
1396 fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
1397 let own = match r.kind.as_str() {
1398 "entry" => Some(&self.entries),
1399 "category" => Some(&self.categories),
1400 _ => None,
1401 };
1402 match (own, RefKind::from_word(&r.kind)) {
1403 (Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
1404 (Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
1405 (None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
1406 (None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
1407 }
1408 }
1409
1410 fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
1411 match t {
1412 Text::Literal(s) => Ok(s.clone()),
1413 Text::NameOf(r) => {
1415 if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
1416 return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
1417 }
1418 self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
1419 let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
1420 self.text_refs = true;
1421 Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
1422 }
1423 Text::Lang(key) => {
1425 self.text_refs = true;
1426 Ok(key.clone())
1427 }
1428 Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
1429 Some((key, _)) => {
1430 self.text_refs = true;
1431 Ok(key.clone())
1432 }
1433 None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1434 },
1435 }
1436 }
1437
1438 fn words(&mut self, r: &Res) -> Option<String> {
1439 self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
1440 }
1441 }
1442
1443 let doc = match yog_data::text::parse(text) {
1445 Ok(d) => d,
1446 Err(e) => {
1447 cx.err(e.at.line as usize, e.message);
1448 return None;
1449 }
1450 };
1451 let ids = |key: &str| -> HashSet<String> {
1452 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()
1453 };
1454 let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
1455 let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
1456 Ok(read) => read.value,
1457 Err(errors) => {
1458 for e in errors.0 {
1459 let line = e.at.map_or(1, |a| a.line as usize);
1460 if e.path.is_root() {
1461 cx.err(line, e.message);
1462 } else {
1463 cx.err(line, format!("{}: {}", e.path, e.message));
1464 }
1465 }
1466 return None;
1467 }
1468 };
1469 if !book.id.contains(':') {
1470 book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
1471 }
1472 let ns = mod_ns.unwrap_or("minecraft");
1474 let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
1475 .chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
1476 .filter(|(_, id)| !id.contains(':'))
1477 .map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
1478 .collect();
1479 if !unnamespaced.is_empty() {
1480 for e in unnamespaced {
1481 cx.err(1, e);
1482 }
1483 return None;
1484 }
1485 if !valid_id(&book.id) {
1486 cx.err(1, format!("book id `{}`: not a valid id", book.id));
1487 return None;
1488 }
1489 let mut used: Vec<yog_book::page_type::PageType> = Vec::new();
1492 let mut fields_ok = true;
1493 for (e, entry) in book.entries.iter_mut().enumerate() {
1494 for (p, page) in entry.pages.iter_mut().enumerate() {
1495 let yog_book::BookPage::Custom(c) = page else { continue };
1496 use yog_book::page_type::PropKind as K;
1497 use yog_data::with::With;
1498 let at = Path::root().key("entries").index(e).key("pages").index(p);
1499 let line = |path: &Path| doc.locate(text, path).line as usize;
1500 let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
1501 if c.kind.split(':').next() == mod_ns {
1502 cx.err(line(&at.key("type")), format!("{}.type: no page type {} — a .yui component with book_page=\"{}\"", at, c.kind, c.kind));
1503 fields_ok = false;
1504 } else {
1505 cx.warn(line(&at.key("type")), format!("{}.type: {} is another mod's page type — not checked here", at, c.kind));
1506 }
1507 continue;
1508 };
1509 for (k, _) in c.fields.iter() {
1510 if k != "anchor" && !t.properties.iter().any(|p| p.name == k) {
1511 let names: Vec<&str> = t.properties.iter().map(|p| p.name.as_str()).collect();
1512 cx.err(line(&at.key(k)), format!("{}: {} has no field `{k}` ({})", at.key(k), c.kind, names.join(", ")));
1513 fields_ok = false;
1514 }
1515 }
1516 for prop in &t.properties {
1517 let path = at.key(&prop.name);
1518 let Some(v) = c.fields.get(&prop.name).cloned() else {
1519 if prop.default.is_none() {
1520 cx.err(line(&at), format!("{at}: missing `{}` ({})", prop.name, c.kind));
1521 fields_ok = false;
1522 }
1523 continue;
1524 };
1525 let arena = yog_data::Bump::new();
1526 let r = v.borrow(&arena);
1527 let mut reader = yog_data::Reader::with_resolver(&mut check);
1528 let read = match prop.kind {
1529 K::Text => yog_data::with::TextOf::read(&r, &mut reader).map(yog_data::Value::Str),
1530 K::BookText => yog_book::refs::BookText::read(&r, &mut reader).map(yog_data::Value::Str),
1531 K::Item => yog_data::with::RefTo::<(yog_data::kinds::Item, yog_data::kinds::Block)>::read(&r, &mut reader).map(yog_data::Value::Str),
1532 K::Image => yog_book::refs::Icon::read(&r, &mut reader).map(yog_data::Value::Str),
1533 K::Number => <f64 as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Float),
1534 K::Bool => <bool as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Bool),
1535 K::Data => Some(v.clone()),
1536 };
1537 let problems = std::mem::take(&mut reader.problems);
1538 drop(reader);
1539 for pr in problems {
1540 cx.err(line(&path), format!("{path}: {}", pr.message));
1541 fields_ok = false;
1542 }
1543 if let Some(out) = read {
1544 c.fields.insert(prop.name.as_str(), out);
1545 }
1546 }
1547 if !used.contains(t) {
1548 used.push(t.clone());
1549 }
1550 }
1551 }
1552 if !fields_ok {
1553 return None;
1554 }
1555 book.page_types = used;
1556 book.i18n |= check.text_refs;
1557 let mut ok = true;
1559 for (e, entry) in book.entries.iter().enumerate() {
1560 for (p, page) in entry.pages.iter().enumerate() {
1561 if let yog_book::BookPage::Recipe { recipe, .. } = page
1562 && let Some(kind) = recipe_kinds.get(recipe)
1563 && yog_book::layout::find(layouts, kind).is_none()
1564 {
1565 let path = yog_data::Path::root().key("entries").index(e).key("pages").index(p).key("recipe");
1566 let short = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
1567 cx.err(
1568 doc.locate(text, &path).line as usize,
1569 format!("{path}: no layout for recipe kind {kind} — lay it out in a .yui: <Component recipe_layout=\"{short}\" width=\"…\" height=\"…\"> (see yog_book::layout)"),
1570 );
1571 ok = false;
1572 }
1573 }
1574 }
1575 if !ok {
1576 return None;
1577 }
1578 book.layouts = layouts.to_vec();
1579 Some(BookFile { id: book.id.clone(), data: yog_data::binary::encode(&yog_data::Schema::write(&book)) })
1580}
1581
1582fn compile_layout(
1586 cx: &mut Ctx,
1587 text: &str,
1588 mod_ns: Option<&str>,
1589 known: &refs::Known,
1590 ores: &mut Vec<OreOut>,
1591 dimensions: &mut Vec<(String, Vec<u8>)>,
1592 tables: &mut Vec<table::Table>,
1593 loot: &mut Vec<(String, Vec<u8>)>,
1594 out: &mut Compiled,
1595) -> Option<yog_formats::def::RecipeKindDef> {
1596 use yog_formats::def::Def;
1597 let doc = match yog_data::text::parse(text) {
1598 Ok(d) => d,
1599 Err(e) => {
1600 cx.err(e.at.line as usize, e.message);
1601 return None;
1602 }
1603 };
1604 let read = match yog_data::schema::from_doc::<Def>(text, &doc) {
1605 Ok(r) => r,
1606 Err(errors) => {
1607 for e in errors.0 {
1608 let l = e.at.map_or(1, |a| a.line as usize);
1609 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1610 }
1611 return None;
1612 }
1613 };
1614 let mut ok = true;
1615 for f in &read.refs {
1616 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1618 continue;
1619 }
1620 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1621 && let Err(e) = known.check(kind, &f.value.id)
1622 {
1623 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1624 ok = false;
1625 }
1626 }
1627 for f in &read.tags {
1628 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1629 continue;
1630 }
1631 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
1632 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1633 ok = false;
1634 }
1635 }
1636 let ns = mod_ns.unwrap_or("minecraft");
1637 let kind = match read.value {
1638 Def::RecipeKind(mut k) => {
1639 if !k.id.contains(':') {
1640 k.id = format!("{ns}:{}", k.id);
1641 }
1642 if k.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1643 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own kind, `{ns}:<kind>`", k.id));
1644 ok = false;
1645 }
1646 k
1647 }
1648 Def::Ore(o) => {
1649 if ok {
1650 ores.push(ore(o, ns));
1651 }
1652 return None;
1653 }
1654 Def::Dimension(d) => {
1655 if ok {
1656 dimensions.push(dimension(d, ns));
1657 }
1658 return None;
1659 }
1660 Def::Tag(t) => {
1661 let line = doc.locate(text, &yog_data::Path::root().key("values")).line as usize;
1662 match tag_entries(&t, ns) {
1663 Ok((path, entries)) => out.tags.entry(path).or_default().extend(entries),
1664 Err(problems) => problems.into_iter().for_each(|p| cx.err(line, p)),
1665 }
1666 return None;
1667 }
1668 Def::DamageType(d) => {
1669 match damage_type(&d, ns) {
1670 Ok((file, tags)) => {
1671 out.outputs.push(file);
1672 for (path, id) in tags {
1673 out.tags.entry(path).or_default().insert(id, true);
1674 }
1675 }
1676 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1677 }
1678 return None;
1679 }
1680 Def::Tree(t) => {
1681 match tree(&t, ns) {
1682 Ok(file) => {
1683 if ok {
1684 out.outputs.push(file);
1685 }
1686 }
1687 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1688 }
1689 return None;
1690 }
1691 Def::LootModifier(mut m) => {
1692 m.id = full_id(&m.id, ns);
1693 if m.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1694 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own, `{ns}:<name>`", m.id));
1695 ok = false;
1696 }
1697 if ok {
1698 use yog_data::Schema;
1699 loot.push((m.id.clone(), yog_data::binary::encode(&m.write())));
1700 }
1701 return None;
1702 }
1703 Def::LootTable(t) => {
1706 let id = full_id(&t.id, ns);
1707 let Some(path) = id.strip_prefix(&format!("{ns}:")).filter(|p| valid_path(p)) else {
1708 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
1709 return None;
1710 };
1711 if ok {
1712 use yog_data::Schema;
1713 out.outputs.push((format!("data/{ns}/loot_table/{path}.json"), pretty(&json!({ "type": "minecraft:generic", "pools": [] }))));
1714 let fill = yog_registry::LootModifier { id: id.clone(), tables: vec![id.clone()], add: t.add, scale: Vec::new() };
1715 loot.push((id, yog_data::binary::encode(&fill.write())));
1716 }
1717 return None;
1718 }
1719 Def::Table(t) => {
1720 match table::check(&cx.file, t, cx.refs, known) {
1721 Ok(t) => tables.push(t),
1722 Err(errors) => {
1723 for e in errors {
1724 let path = e.split_once(':').map_or("", |(p, _)| p);
1726 let at = path.split('.').fold(yog_data::Path::root(), |at, k| at.key(k));
1727 cx.err(doc.locate(text, &at).line as usize, e);
1728 }
1729 }
1730 }
1731 return None;
1732 }
1733 _ => return None,
1734 };
1735 ok.then_some(kind)
1736}
1737
1738fn item_props(p: &ItemProps) -> String {
1741 let mut b = String::new();
1742 if let Some(s) = p.stack_size {
1743 let _ = write!(b, "\n .max_stack({s})");
1744 }
1745 if p.durability > 0 {
1746 let _ = write!(b, "\n .max_damage({})", p.durability);
1747 }
1748 if p.fire_resistant {
1749 b.push_str("\n .fire_resistant()");
1750 }
1751 if p.fuel_ticks > 0 {
1752 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
1753 }
1754 if let Some((kind, material, d, s)) = &p.tool {
1755 let _ = write!(b, "\n .tool({kind:?}, {material:?}, {d:?}, {s:?})");
1756 }
1757 if let Some(slot) = &p.wearable {
1758 let _ = write!(b, "\n .wearable({slot:?})");
1759 }
1760 if let Some((slot, material)) = &p.armor {
1761 let _ = write!(b, "\n .armor({slot:?}, {material:?})");
1762 }
1763 if let Some((n, s, always)) = p.food {
1764 let _ = write!(
1765 b,
1766 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
1767 float(s),
1768 if always { ".can_always_eat()" } else { "" }
1769 );
1770 }
1771 b
1772}
1773
1774fn loot_path(id: &str) -> String {
1776 format!("loot/{}.ydata", id.replace([':', '/'], "__"))
1777}
1778
1779fn full_id(id: &str, ns: &str) -> String {
1781 if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") }
1782}
1783
1784const TAG_REF: &str = "tag:";
1786
1787fn tag_entries(t: &yog_formats::def::TagDef, ns: &str) -> Result<(String, BTreeMap<String, bool>), Vec<String>> {
1790 use yog_data::AnyRefOrTag;
1791 let id = full_id(&t.id, ns);
1792 let (tns, tpath) = id.split_once(':').unwrap_or(("minecraft", &id));
1793 let mut problems = Vec::new();
1794 let mut entries = BTreeMap::new();
1795 for (list, required) in [(&t.values, true), (&t.optional, false)] {
1796 for v in list {
1797 if v.kind() != t.of {
1798 problems.push(format!("`{v}` in a tag of {}", t.of));
1799 continue;
1800 }
1801 let (entry, required) = match v {
1802 AnyRefOrTag::One(r) => (full_id(&r.id, "minecraft"), required),
1803 AnyRefOrTag::Tag(r) => {
1805 let tag = full_id(&r.id, "minecraft");
1806 let everywhere = !matches!(refs::tag_hosts(refs::tag_registry(&t.of), &tag), Some(Some(_)));
1807 (format!("#{tag}"), required && everywhere)
1808 }
1809 };
1810 entries.insert(entry, required);
1811 }
1812 }
1813 if problems.is_empty() {
1814 Ok((format!("data/{tns}/tags/{}/{tpath}.json", refs::tag_registry(&t.of)), entries))
1815 } else {
1816 Err(problems)
1817 }
1818}
1819
1820fn tree(t: &yog_formats::def::TreeDef, ns: &str) -> Result<Output, String> {
1822 use serde_json::json;
1823 let id = full_id(&t.id, ns);
1824 let (tns, tpath) = id.split_once(':').unwrap_or((ns, &id));
1825 if tns != ns || !valid_path(tpath) {
1826 return Err(format!("id: `{}`: the mod's own tree, `{ns}:<name>`", t.id));
1827 }
1828 let provider = |blocks: &[yog_formats::def::WeightedBlock]| match blocks {
1830 [one] => json!({ "type": "minecraft:simple_state_provider", "state": { "Name": one.block.id } }),
1831 many => json!({
1832 "type": "minecraft:weighted_state_provider",
1833 "entries": many.iter().map(|b| json!({ "data": { "Name": b.block.id }, "weight": b.weight })).collect::<Vec<_>>()
1834 }),
1835 };
1836 let placer = |shape: &str, what: &str| if shape.contains(':') { shape.to_string() } else { format!("minecraft:{shape}_{what}_placer") };
1838 if t.trunk.blocks.is_empty() || t.leaves.blocks.is_empty() {
1839 return Err("a tree needs trunk and leaves blocks".into());
1840 }
1841 let mut foliage = json!({ "type": placer(&t.leaves.shape, "foliage"), "radius": t.leaves.radius, "offset": t.leaves.offset });
1842 if let Some(h) = t.leaves.height {
1843 foliage["height"] = h.into();
1844 }
1845 let mut size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 1, "lower_size": 0, "upper_size": 1 });
1846 if let Some(h) = t.min_clipped_height {
1847 size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 0, "lower_size": 0, "upper_size": 0, "min_clipped_height": h });
1848 }
1849 let dirt = t.dirt.as_ref().map_or("minecraft:dirt", |b| b.id.as_str());
1850 let v = json!({
1851 "type": "minecraft:tree",
1852 "config": {
1853 "trunk_provider": provider(&t.trunk.blocks),
1854 "trunk_placer": { "type": placer(&t.trunk.shape, "trunk"), "base_height": t.trunk.base_height, "height_rand_a": t.trunk.random_a, "height_rand_b": t.trunk.random_b },
1855 "foliage_provider": provider(&t.leaves.blocks),
1856 "foliage_placer": foliage,
1857 "dirt_provider": { "type": "minecraft:simple_state_provider", "state": { "Name": dirt } },
1858 "minimum_size": size,
1859 "decorators": [],
1860 "ignore_vines": t.ignore_vines,
1861 "force_dirt": t.force_dirt
1862 }
1863 });
1864 Ok((format!("data/{tns}/worldgen/configured_feature/{tpath}.json"), pretty(&v)))
1865}
1866
1867fn damage_type(d: &yog_formats::def::DamageTypeDef, ns: &str) -> Result<(Output, Vec<(String, String)>), String> {
1870 let id = full_id(&d.id, ns);
1871 let (dns, dpath) = id.split_once(':').unwrap_or((ns, &id));
1872 if dns != ns || !valid_path(dpath) {
1873 return Err(format!("id: `{}`: the mod's own damage type, `{ns}:<name>`", d.id));
1874 }
1875 let mut v = serde_json::json!({
1876 "message_id": d.message_id.clone().unwrap_or_else(|| format!("{dns}.{dpath}")),
1877 "scaling": d.scaling,
1878 "exhaustion": d.exhaustion,
1879 });
1880 if let Some(e) = &d.effects {
1881 v["effects"] = Value::String(e.clone());
1882 }
1883 if let Some(m) = &d.death_message_type {
1884 v["death_message_type"] = Value::String(m.clone());
1885 }
1886 let tags = d
1887 .tags
1888 .iter()
1889 .map(|t| {
1890 let t = full_id(t, "minecraft");
1891 let (tns, tpath) = t.split_once(':').unwrap_or(("minecraft", &t));
1892 (format!("data/{tns}/tags/damage_type/{tpath}.json"), id.clone())
1893 })
1894 .collect();
1895 Ok(((format!("data/{dns}/damage_type/{dpath}.json"), pretty(&v)), tags))
1896}
1897
1898fn recipe_layouts(
1902 root: &Path,
1903 ns: &str,
1904 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
1905 known: &refs::Known,
1906 errors: &mut Vec<String>,
1907) -> Vec<yog_book::layout::RecipeLayout> {
1908 use yog_book::layout::LayoutPart;
1909 let mut laid_out: HashMap<String, String> = HashMap::new();
1910 let mut out = Vec::new();
1911 for (shown, l) in crate::book_page::find_layouts(root, ns, errors) {
1912 let mut ok = true;
1913 for k in &l.kinds {
1914 let game = yog_formats::recipe::TYPES.contains(&k.as_str());
1915 if !game && !kinds.contains_key(k) {
1916 errors.push(format!(
1917 "{shown}: recipe_layout: `{k}`: one of {}, or a kind the mod declares (a .ydef of kind recipe_kind)",
1918 yog_formats::recipe::TYPES.join(", ")
1919 ));
1920 ok = false;
1921 }
1922 if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
1923 errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
1924 ok = false;
1925 }
1926 }
1927 for part in &l.parts {
1928 match part {
1929 LayoutPart::Slots { slot, .. } => {
1930 for k in l.kinds.iter().filter_map(|k| kinds.get(k)) {
1931 if !k.slots.0.iter().any(|(s, _)| s == slot) {
1932 let all: Vec<&str> = k.slots.0.iter().map(|(s, _)| s.as_str()).collect();
1933 errors.push(format!("{shown}: <RecipeSlot slot=\"{slot}\">: {} has no slot `{slot}` — {}", k.id, all.join(", ")));
1934 ok = false;
1935 }
1936 }
1937 }
1938 LayoutPart::Item { item, .. } => {
1939 if let Err(e) = known.check(refs::RefKind::Item, item) {
1940 errors.push(format!("{shown}: <ItemSlot item=\"{item}\">: {e}"));
1941 ok = false;
1942 }
1943 }
1944 _ => {}
1945 }
1946 }
1947 if ok {
1948 out.push(l);
1949 }
1950 }
1951 out
1952}
1953
1954fn dimension(d: yog_formats::def::DimensionDef, ns: &str) -> (String, Vec<u8>) {
1958 use yog_data::{Map, Schema, Value as V};
1959 let id = if d.id.contains(':') { d.id.clone() } else { format!("{ns}:{}", d.id) };
1960 let mut m = Map::new();
1961 m.insert("id", V::from(id.as_str()));
1962 m.insert("dimension_type", d.dimension_type.write());
1963 m.insert("biome", V::from(d.biome.map_or("minecraft:plains".to_string(), |b| b.id).as_str()));
1964 m.insert("structures", V::Bool(d.structures));
1965 m.insert("features", V::Bool(d.features));
1966 m.insert("mob_spawning", V::Bool(d.mob_spawning));
1967 if let Some(t) = &d.terrain {
1968 m.insert("generator_type", V::from(id.as_str()));
1969 m.insert("terrain", t.write());
1970 } else if let Some(g) = &d.generator {
1971 m.insert("generator_type", V::from(if g.contains(':') { g.clone() } else { format!("{ns}:{g}") }.as_str()));
1972 }
1973 (id, yog_data::binary::encode(&V::Map(m)))
1974}
1975
1976type OreOut = (Vec<Output>, (String, String, String));
1978
1979fn ore(o: yog_formats::def::OreDef, ns: &str) -> OreOut {
1983 use serde_json::json;
1984 let p = o.id.split_once(':').map_or(o.id.as_str(), |(_, p)| p).to_string();
1985 let id = format!("{ns}:{p}");
1986 let targets: Vec<Value> = o
1987 .targets
1988 .iter()
1989 .map(|t| {
1990 let target = match t.replaces.strip_prefix('#') {
1991 Some(tag) => json!({ "predicate_type": "minecraft:tag_match", "tag": tag }),
1992 None => json!({ "predicate_type": "minecraft:block_match", "block": t.replaces }),
1993 };
1994 json!({ "target": target, "state": { "Name": t.block.id } })
1995 })
1996 .collect();
1997 let configured = json!({
1998 "type": "minecraft:ore",
1999 "config": { "size": o.size, "discard_chance_on_air_exposure": o.air_exposure, "targets": targets }
2000 });
2001 let shape = match o.height.shape {
2002 yog_formats::def::HeightShape::Uniform => "minecraft:uniform",
2003 yog_formats::def::HeightShape::Triangle => "minecraft:trapezoid",
2004 };
2005 let placed = json!({
2006 "feature": id,
2007 "placement": [
2008 { "type": "minecraft:count", "count": o.per_chunk },
2009 { "type": "minecraft:in_square" },
2010 { "type": "minecraft:height_range", "height": { "type": shape, "min_inclusive": { "absolute": o.height.min }, "max_inclusive": { "absolute": o.height.max } } },
2011 { "type": "minecraft:biome" }
2012 ]
2013 });
2014 let biomes = o.biomes.map(|b| b.id).unwrap_or_else(|| "minecraft:is_overworld".into());
2015 let modifier = |kind: &str| json!({ "type": kind, "biomes": format!("#{biomes}"), "features": id, "step": "underground_ores" });
2016 let mut files: Vec<Output> = Vec::new();
2017 let mut put = |path: String, v: Value| files.push((path, pretty(&v)));
2018 put(format!("data/{ns}/worldgen/configured_feature/{p}.json"), configured);
2019 put(format!("data/{ns}/worldgen/placed_feature/{p}.json"), placed);
2020 for (dir, kind) in [("when/forge/any", "forge:add_features"), ("when/neoforge/1.20", "forge:add_features")] {
2021 put(format!("{dir}/data/{ns}/forge/biome_modifier/{p}.json"), modifier(kind));
2022 }
2023 put(format!("when/neoforge/1.21/data/{ns}/neoforge/biome_modifier/{p}.json"), modifier("neoforge:add_features"));
2024 (files, (id, biomes, "underground_ores".into()))
2025}
2026
2027#[derive(Debug, Clone)]
2029struct Recipe {
2030 id: String,
2031 ty: String,
2033 kind: RecipeKind,
2034 result: String,
2035 count: u32,
2036 group: Option<String>,
2037 category: Option<String>,
2038 requires: Vec<String>,
2040}
2041
2042impl Recipe {
2043 fn registration(&self) -> String {
2045 let list = |items: &[String]| {
2046 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
2047 format!("vec![{}]", quoted.join(", "))
2048 };
2049 let kind = match &self.kind {
2050 RecipeKind::Shaped { pattern, key } => {
2051 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
2052 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
2053 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
2054 }
2055 RecipeKind::Shapeless(ingredients) => {
2056 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
2057 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
2058 }
2059 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
2060 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
2061 list(input),
2062 float(*experience)
2063 ),
2064 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
2065 RecipeKind::Custom { kind, slots, values } => {
2066 let mut o = Map::new();
2068 o.insert("type".into(), kind.clone().into());
2069 o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
2070 if !values.is_empty() {
2071 o.insert("values".into(), Value::Object(values.clone()));
2072 }
2073 o.insert("result".into(), self.result.clone().into());
2074 if self.count != 1 {
2075 o.insert("count".into(), self.count.into());
2076 }
2077 if let Some(g) = &self.group {
2078 o.insert("group".into(), g.clone().into());
2079 }
2080 return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
2081 }
2082 RecipeKind::Smithing { template, base, addition } => format!(
2083 "Smithing {{ template: {}, base: {}, addition: {} }}",
2084 list(template),
2085 list(base),
2086 list(addition)
2087 ),
2088 };
2089 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
2090 format!(
2091 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
2092 self.id,
2093 self.result,
2094 self.count,
2095 opt(&self.group),
2096 opt(&self.category)
2097 )
2098 }
2099}
2100
2101fn compile_recipe(
2105 cx: &mut Ctx,
2106 text: &str,
2107 path: &Path,
2108 mod_ns: Option<&str>,
2109 known: &refs::Known,
2110 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
2111) -> Option<Recipe> {
2112 use yog_formats::recipe::{Body, RecipeFile};
2113 use yog_data::Path as At;
2114
2115 let doc = match yog_data::text::parse(text) {
2116 Ok(d) => d,
2117 Err(e) => {
2118 cx.err(e.at.line as usize, e.message);
2119 return None;
2120 }
2121 };
2122 let line = |p: &At| doc.locate(text, p).line as usize;
2123 let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
2124 Ok(r) => r,
2125 Err(errors) => {
2126 for e in errors.0 {
2127 let l = e.at.map_or(1, |a| a.line as usize);
2128 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
2129 }
2130 return None;
2131 }
2132 };
2133 let mut ok = true;
2134 for f in &read.refs {
2135 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
2136 && let Err(e) = known.check(kind, &f.value.id)
2137 {
2138 cx.err(line(&f.path), format!("{}: {e}", f.path));
2139 ok = false;
2140 }
2141 }
2142 for f in &read.tags {
2143 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
2144 cx.err(line(&f.path), format!("{}: {e}", f.path));
2145 ok = false;
2146 }
2147 }
2148 if ok {
2150 for (missing, why) in recipe_gaps(&read.refs, &read.tags) {
2151 cx.warn(line(&At::root()), format!("left out on {}: {why}", refs::describe_hosts(&missing)));
2152 }
2153 }
2154 let RecipeFile { common: c, body } = read.value;
2155 let ty = match &body {
2156 Body::Shaped(_) => "shaped".to_string(),
2157 Body::Shapeless(_) => "shapeless".to_string(),
2158 Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
2159 Body::Stonecutting(_) => "stonecutting".to_string(),
2160 Body::Smithing(_) => "smithing".to_string(),
2161 };
2162 let root = At::root();
2163 if let Some(f) = c.format
2164 && f != 1
2165 {
2166 cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
2167 ok = false;
2168 }
2169 let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
2171 let id_line = line(&root.key("id"));
2172 let (ns, p) = match raw.split_once(':') {
2173 Some((ns, p)) => (ns.to_string(), p.to_string()),
2174 None => match mod_ns {
2175 Some(ns) => (ns.to_string(), raw.clone()),
2176 None => {
2177 cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
2178 return None;
2179 }
2180 },
2181 };
2182 if !valid_namespace(&ns) || !valid_path(&p) {
2183 cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
2184 return None;
2185 }
2186 if !(1..=64).contains(&c.count) {
2187 cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
2188 ok = false;
2189 }
2190 let kind = match body {
2191 Body::Shaped(s) => {
2192 let width = s.pattern.first().map_or(0, |r| r.chars().count());
2193 if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
2194 cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
2195 return None;
2196 }
2197 let mut key = Vec::new();
2198 for (k, ing) in &s.key.0 {
2199 let at = line(&root.key("key").key(k));
2200 let mut chars = k.chars();
2201 match (chars.next(), chars.next()) {
2202 (Some(ch), None) if ch != ' ' => {
2203 if !s.pattern.iter().any(|r| r.contains(ch)) {
2204 cx.warn(at, format!("key {k:?} isn't used by the pattern"));
2205 }
2206 key.push((ch, ing.ids()));
2207 }
2208 _ => {
2209 cx.err(at, format!("key {k:?}: one character other than space"));
2210 ok = false;
2211 }
2212 }
2213 }
2214 for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
2215 if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
2216 cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
2217 return None;
2218 }
2219 }
2220 RecipeKind::Shaped { pattern: s.pattern, key }
2221 }
2222 Body::Shapeless(s) => {
2223 if s.ingredients.is_empty() || s.ingredients.len() > 9 {
2224 cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
2225 return None;
2226 }
2227 RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
2228 }
2229 Body::Cooking(ty, k) => {
2230 let (method, default_time) = match ty.as_str() {
2231 "smelting" => ("Smelting", 200),
2232 "blasting" => ("Blasting", 100),
2233 "smoking" => ("Smoking", 100),
2234 _ => ("Campfire", 600),
2235 };
2236 if !(0.0..=1000.0).contains(&k.experience) {
2237 cx.err(line(&root.key("experience")), "experience: 0–1000");
2238 ok = false;
2239 }
2240 let cook_time = k.cook_time.unwrap_or(default_time);
2241 if !(1..=32767).contains(&cook_time) {
2242 cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
2243 ok = false;
2244 }
2245 RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
2246 }
2247 Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
2248 Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
2249 Body::Custom(kind, k) => {
2250 let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
2252 if !own {
2253 cx.err(
2254 line(&root.key("type")),
2255 format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
2256 );
2257 return None;
2258 }
2259 if c.category.is_some() {
2260 cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
2261 ok = false;
2262 }
2263 let Some(def) = kinds.get(&kind) else {
2265 let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
2266 cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
2267 return None;
2268 };
2269 for (name, ings) in &k.slots.0 {
2270 let at = root.key("slots").key(name);
2271 match def.slots.0.iter().find(|(n, _)| n == name) {
2272 None => {
2273 let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
2274 cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
2275 ok = false;
2276 }
2277 Some((_, spec)) if ings.len() > spec.max as usize => {
2278 cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
2279 ok = false;
2280 }
2281 _ => {}
2282 }
2283 }
2284 for (name, spec) in &def.slots.0 {
2285 if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
2286 cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
2287 ok = false;
2288 }
2289 }
2290 for (name, v) in &k.values.0 {
2291 let at = root.key("values").key(name);
2292 let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
2293 None => {
2294 let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
2295 cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
2296 ok = false;
2297 continue;
2298 }
2299 Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
2300 Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
2301 Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
2302 };
2303 if !fits {
2304 cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
2305 ok = false;
2306 }
2307 }
2308 let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
2309 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();
2310 RecipeKind::Custom { kind, slots, values }
2311 }
2312 };
2313 ok.then(|| Recipe {
2314 id: format!("{ns}:{p}"),
2315 ty,
2316 kind,
2317 result: c.result.id,
2318 count: c.count,
2319 group: c.group,
2320 category: c.category,
2321 requires: c.requires,
2322 })
2323}
2324
2325fn recipe_gaps(items: &[yog_data::schema::Found<yog_data::Ref>], tags: &[yog_data::schema::Found<yog_data::Ref>]) -> Vec<(BTreeSet<String>, String)> {
2329 use refs::RefKind;
2330 let mut lists: BTreeMap<String, Vec<(String, Option<BTreeSet<String>>)>> = BTreeMap::new();
2332 let parent = |p: &yog_data::Path| {
2333 let mut p = p.clone();
2334 if matches!(p.segments().last(), Some(yog_data::Seg::Index(_))) {
2335 p.pop();
2336 }
2337 p.to_string()
2338 };
2339 for f in items {
2340 let Some(kind) = RefKind::from_word(&f.value.kind) else { continue };
2341 if !f.value.id.starts_with("minecraft:") {
2342 continue;
2343 }
2344 let on: BTreeSet<String> = refs::hosts()
2345 .iter()
2346 .filter(|h| h.split_once('/').is_some_and(|(_, v)| refs::equivalent(kind, &f.value.id, v).is_some()))
2347 .cloned()
2348 .collect();
2349 let on = (on.len() < refs::hosts().len()).then_some(on);
2350 lists.entry(parent(&f.path)).or_default().push((f.value.id.clone(), on));
2351 }
2352 for f in tags {
2353 let Some(on) = refs::tag_hosts(refs::tag_registry(&f.value.kind), &f.value.id) else { continue };
2354 lists.entry(parent(&f.path)).or_default().push((format!("#{}", f.value.id), on));
2355 }
2356 let mut out = Vec::new();
2357 for alts in lists.values() {
2358 if alts.iter().any(|(_, on)| on.is_none()) {
2359 continue;
2360 }
2361 let on: BTreeSet<String> = alts.iter().flat_map(|(_, on)| on.iter().flatten().cloned()).collect();
2362 let missing: BTreeSet<String> = refs::hosts().iter().filter(|h| !on.contains(*h)).cloned().collect();
2363 if missing.is_empty() {
2364 continue;
2365 }
2366 let names: Vec<&str> = alts.iter().map(|(id, _)| id.as_str()).collect();
2367 let hint = alts
2369 .iter()
2370 .filter_map(|(id, _)| id.strip_prefix('#').and_then(|t| refs::yog_tag_for("item", t)))
2371 .find(|y| refs::tag_hosts("item", y) == Some(None))
2372 .map(|y| format!(" — @tag:item:{y} is on every version and loader"))
2373 .unwrap_or_default();
2374 out.push((missing, format!("{} isn't there{hint}", names.join(" / "))));
2375 }
2376 out
2377}
2378
2379struct Defs {
2382 order: BTreeMap<String, Vec<String>>,
2383 sound_groups: Vec<yog_formats::def::SoundGroup>,
2384 tool_materials: Vec<yog_formats::def::ToolMaterial>,
2385 armor_materials: Vec<yog_formats::def::ArmorMaterial>,
2386 biome_features: Vec<(String, String, String)>,
2388 dimensions: Vec<String>,
2390 tables: Vec<table::Table>,
2392 loot_modifiers: Vec<String>,
2394}
2395
2396impl Defs {
2397 fn level(&self, id: &str) -> Option<(&str, usize)> {
2399 self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
2400 }
2401}
2402
2403fn read_defs(defs: &[(String, String)], mod_ns: Option<&str>, errors: &mut Vec<String>) -> Defs {
2406 let mut order: BTreeMap<String, Vec<String>> = BTreeMap::new();
2407 let mut sound_groups: Vec<yog_formats::def::SoundGroup> = Vec::new();
2408 order.insert(yog_formats::def::TOOL_LADDER.into(), yog_formats::def::TOOL_LEVELS.iter().map(|l| format!("minecraft:{l}")).collect());
2409 let ns = mod_ns.unwrap_or("minecraft");
2410 let full = |id: &str| if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") };
2411 let mut levels: Vec<(String, usize, String, String, Option<String>)> = Vec::new();
2413 let mut tools: Vec<(String, yog_formats::def::ToolMaterialDef)> = Vec::new();
2414 let mut armor_materials: Vec<yog_formats::def::ArmorMaterial> = Vec::new();
2415 let repair = |r: &yog_data::ItemIdOrTag| match r {
2416 yog_data::OneOrTag::One(i) => i.id.clone(),
2417 yog_data::OneOrTag::Tag(t) => format!("#{}", t.id),
2418 };
2419 for (file, text) in defs {
2420 let doc = match yog_data::text::parse(text) {
2421 Ok(d) => d,
2422 Err(e) => {
2423 errors.push(format!("{file}:{}: {}", e.at.line, e.message));
2424 continue;
2425 }
2426 };
2427 match yog_data::schema::from_doc::<yog_formats::def::Def>(text, &doc) {
2428 Ok(r) => match r.value {
2429 yog_formats::def::Def::Ladder { id } => {
2430 if order.insert(full(&id), Vec::new()).is_some() {
2431 errors.push(format!("{file}:1: ladder {} is defined twice", full(&id)));
2432 }
2433 }
2434 yog_formats::def::Def::Level { id, ladder, above } => levels.push((file.clone(), doc.locate(text, &At::root().key("id")).line as usize, full(&id), ladder, above)),
2435 yog_formats::def::Def::SoundGroup(mut g) => {
2436 for s in [&mut g.id, &mut g.break_sound, &mut g.step, &mut g.place, &mut g.hit, &mut g.fall] {
2438 *s = full(s);
2439 }
2440 if yog_formats::def::SoundGroup::built_in(&g.id).is_some() {
2441 errors.push(format!("{file}:1: sound group {} is the game's", g.id));
2442 } else if sound_groups.iter().any(|x| x.id == g.id) {
2443 errors.push(format!("{file}:1: sound group {} is defined twice", g.id));
2444 } else {
2445 sound_groups.push(g);
2446 }
2447 }
2448 yog_formats::def::Def::RecipeKind(_) | yog_formats::def::Def::Ore(_) | yog_formats::def::Def::Dimension(_) | yog_formats::def::Def::Table(_) | yog_formats::def::Def::Tag(_) | yog_formats::def::Def::DamageType(_) | yog_formats::def::Def::Tree(_) | yog_formats::def::Def::LootModifier(_) | yog_formats::def::Def::LootTable(_) => {}
2450 yog_formats::def::Def::ToolMaterial(mut m) => {
2452 m.id = full(&m.id);
2453 tools.push((file.clone(), m));
2454 }
2455 yog_formats::def::Def::ArmorMaterial(m) => {
2456 let id = full(&m.id);
2457 if yog_formats::def::ArmorMaterial::built_in(&id).is_some() || armor_materials.iter().any(|x| x.id == id) {
2458 errors.push(format!("{file}:1: armor material {id} is the game's or defined twice"));
2459 continue;
2460 }
2461 armor_materials.push(yog_formats::def::ArmorMaterial {
2462 texture: m.texture.as_deref().map(full).unwrap_or_else(|| id.clone()),
2463 equip_sound: full(&m.equip_sound),
2464 durability: m.durability,
2465 defense: m.defense,
2466 enchantability: m.enchantability,
2467 toughness: m.toughness,
2468 knockback_resistance: m.knockback_resistance,
2469 repair: repair(&m.repair),
2470 id,
2471 });
2472 }
2473 },
2474 Err(es) => {
2475 for e in es.0 {
2476 let l = e.at.map_or(1, |a| a.line as usize);
2477 errors.push(if e.path.is_root() { format!("{file}:{l}: {}", e.message) } else { format!("{file}:{l}: {}: {}", e.path, e.message) });
2478 }
2479 }
2480 }
2481 }
2482 loop {
2484 let before = levels.len();
2485 levels.retain(|(file, line, id, ladder, above)| {
2486 let Some(list) = order.get_mut(ladder) else {
2487 errors.push(format!("{file}:{line}: level {id}: no ladder {ladder}"));
2488 return false;
2489 };
2490 let at = match above {
2491 None => 0,
2492 Some(a) => match list.iter().position(|x| x == a) {
2493 Some(i) => i + 1,
2494 None => return true, },
2496 };
2497 if list.contains(id) {
2498 errors.push(format!("{file}:{line}: level {id} is defined twice"));
2499 } else {
2500 list.insert(at, id.clone());
2501 }
2502 false
2503 });
2504 if levels.is_empty() || levels.len() == before {
2505 break;
2506 }
2507 }
2508 for (file, line, id, ladder, above) in levels {
2509 let above = above.unwrap_or_default();
2510 errors.push(format!("{file}:{line}: level {id}: above {above}, which isn't a level of {ladder} (or the levels go round in a circle)"));
2511 }
2512 let ladder = order.get(yog_formats::def::TOOL_LADDER).cloned().unwrap_or_default();
2517 let mut tool_materials: Vec<yog_formats::def::ToolMaterial> = Vec::new();
2518 for (file, m) in tools {
2519 if yog_formats::def::ToolMaterial::built_in(&m.id).is_some() || tool_materials.iter().any(|x| x.id == m.id) {
2520 errors.push(format!("{file}:1: tool material {} is the game's or defined twice", m.id));
2521 continue;
2522 }
2523 let Some(at) = ladder.iter().position(|l| l == &m.level) else {
2524 errors.push(format!("{file}:1: level: {} isn't a level of the tool ladder", m.level));
2525 continue;
2526 };
2527 let place = |v: &str| ladder.iter().position(|x| x == &format!("minecraft:{v}")).unwrap_or(0);
2528 let game = ["wood", "stone", "iron", "diamond", "netherite"];
2529 let (level_index, incorrect) = match game.iter().position(|v| place(v) >= at) {
2530 Some(i) => (i as u32, format!("minecraft:incorrect_for_{}_tool", if game[i] == "wood" { "wooden" } else { game[i] })),
2531 None => {
2533 let (n, p) = m.level.split_once(':').unwrap_or(("minecraft", &m.level));
2534 (5, format!("{n}:incorrect_for_{p}_tool"))
2535 }
2536 };
2537 tool_materials.push(yog_formats::def::ToolMaterial {
2538 id: m.id,
2539 durability: m.durability,
2540 speed: m.speed,
2541 attack_damage: m.attack_damage,
2542 enchantability: m.enchantability,
2543 level: m.level,
2544 repair: repair(&m.repair),
2545 level_index,
2546 incorrect,
2547 });
2548 }
2549 Defs { order, sound_groups, tool_materials, armor_materials, biome_features: Vec::new(), dimensions: Vec::new(), tables: Vec::new(), loot_modifiers: Vec::new() }
2550}
2551
2552fn tool_level_tags(id: &str, level: &str, ladders: &Defs, out: &mut Compiled, err: &mut dyn FnMut(String), warn: &mut dyn FnMut(String)) {
2557 let Some((ladder, at)) = ladders.level(level) else { return };
2558 if ladder != yog_formats::def::TOOL_LADDER {
2559 err(format!("{level} is a level of {ladder}, not of the tool ladder ({})", yog_formats::def::TOOL_LADDER));
2560 return;
2561 }
2562 let tools = &ladders.order[yog_formats::def::TOOL_LADDER];
2563 let place = |l: &str| tools.iter().position(|x| x == &format!("minecraft:{l}")).unwrap_or(0);
2564 let able = ["wood", "stone", "iron", "diamond", "netherite"].into_iter().find(|v| place(v) >= at);
2566 let mut tag = |path: String| {
2567 out.tags.entry(path).or_default().insert(id.to_string(), true);
2568 };
2569 match able {
2570 Some("wood") => {}
2571 Some(v @ ("stone" | "iron" | "diamond")) => tag(format!("data/minecraft/tags/block/needs_{v}_tool.json")),
2572 _ => {
2573 tag("data/minecraft/tags/block/needs_diamond_tool.json".into());
2575 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json".into());
2576 tag(format!("when/fabric/1.20/data/fabric/tags/block/needs_tool_level_{}.json", if able.is_some() { 4 } else { 5 }));
2577 for loader in ["forge", "neoforge"] {
2578 tag(format!("when/{loader}/1.20/data/forge/tags/block/needs_netherite_tool.json"));
2579 }
2580 if able.is_none() {
2581 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_netherite_tool.json".into());
2582 warn("above netherite: on Forge and NeoForge 1.20 a netherite tool still mines it (their tiers stop at netherite)".into());
2583 }
2584 }
2585 }
2586}
2587
2588fn inventory_screens(dir: &Path) -> BTreeSet<String> {
2591 let mut out = BTreeSet::new();
2592 let Ok(entries) = std::fs::read_dir(dir) else {
2593 return out;
2594 };
2595 for e in entries.flatten() {
2596 let path = e.path();
2597 if path.is_dir() {
2598 out.extend(inventory_screens(&path));
2599 continue;
2600 }
2601 if path.extension().is_none_or(|x| x != "yui") {
2602 continue;
2603 }
2604 let Ok(text) = std::fs::read_to_string(&path) else { continue };
2605 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
2606 continue;
2607 }
2608 if let Some(screen) = text.find("<Screen")
2609 && let Some(at) = text[screen..].find(" id=\"")
2610 {
2611 let rest = &text[screen + at + 5..];
2612 if let Some(end) = rest.find('"') {
2613 out.insert(rest[..end].to_string());
2614 }
2615 }
2616 }
2617 out
2618}
2619
2620pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
2623 let mut out = Compiled::default();
2624 let files = find(root);
2625 if files.is_empty() {
2626 return Ok(out);
2627 }
2628 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
2629 let mut errors: Vec<String> = Vec::new();
2630 let mut warnings: Vec<String> = Vec::new();
2631 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
2632 let mut generated: BTreeMap<String, String> = BTreeMap::new();
2633 let mut entries: Vec<Entry> = Vec::new();
2634 let mut ids: HashMap<String, String> = HashMap::new();
2635 let mut recipes: Vec<Recipe> = Vec::new();
2636 let mut recipe_ids: HashMap<String, String> = HashMap::new();
2637 let mut books: Vec<BookFile> = Vec::new();
2638 let mut book_ids: HashMap<String, String> = HashMap::new();
2639 let mut bodies: Vec<BodyFile> = Vec::new();
2640 let mut entity_files: Vec<EntityFile> = Vec::new();
2641 let mut body_ids: HashMap<(bool, String), String> = HashMap::new();
2642 let mut inventories: Vec<(String, usize, String)> = Vec::new();
2643 let mut pending_books: Vec<(String, String)> = Vec::new();
2644 let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
2645 let mut pending_layouts: Vec<(String, String)> = Vec::new();
2646 let mut pending_defs: Vec<(String, String)> = Vec::new();
2647 let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
2648 for path in &files {
2649 let shown = display(root, path);
2650 let Some(kind) = Kind::of(path) else { continue };
2651 let text = match std::fs::read_to_string(path) {
2652 Ok(t) => t,
2653 Err(e) => {
2654 errors.push(format!("{shown}: {e}"));
2655 continue;
2656 }
2657 };
2658 if matches!(kind, Kind::Model | Kind::Anim) {
2659 if let Some(b) = compile_body(&shown, &text, kind, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
2660 if let Some(other) = body_ids.insert((kind == Kind::Model, b.id.clone()), shown.clone()) {
2661 errors.push(format!("{shown}: {} {} is also defined by {other}", kind.word(), b.id));
2662 }
2663 bodies.push(b);
2664 }
2665 continue;
2666 }
2667 if kind == Kind::Book {
2668 pending_books.push((shown.clone(), text));
2670 continue;
2671 }
2672 if kind == Kind::Recipe {
2673 pending_recipes.push((shown.clone(), text, path.clone()));
2675 continue;
2676 }
2677 if kind == Kind::Def {
2678 let of = yog_data::text::parse_value(&text).ok().and_then(|v| v.get("kind").and_then(|k| k.as_str()).map(str::to_string));
2680 if matches!(of.as_deref(), Some("recipe_kind" | "ore" | "dimension" | "table" | "tag" | "damage_type" | "tree" | "loot_modifier" | "loot_table")) {
2681 pending_layouts.push((shown.clone(), text));
2682 } else {
2683 pending_defs.push((shown.clone(), text));
2684 }
2685 continue;
2686 }
2687 let mut cx = Ctx {
2688 root,
2689 refs,
2690 file: shown.clone(),
2691 ns: mod_ns.unwrap_or("minecraft").to_string(),
2692 errors: &mut errors,
2693 warnings: &mut warnings,
2694 textures: &mut textures,
2695 inventories: &mut inventories,
2696 deferred: &mut deferred,
2697 };
2698 if kind == Kind::Entity {
2699 if let Some(e) = compile_entity(&mut cx, &text, path, mod_ns, &mut out) {
2700 if let Some(other) = ids.insert(e.id.clone(), shown.clone()) {
2701 errors.push(format!("{shown}: id {} is also defined by {other}", e.id));
2702 }
2703 entity_files.push(e);
2704 }
2705 continue;
2706 }
2707 if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
2708 {
2709 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2710 errors.push(format!(
2711 "{shown}: id {} is also defined by {other}",
2712 entry.id
2713 ));
2714 }
2715 entries.push(entry);
2716 }
2717 }
2718 let manifest = crate::manifest::YogToml::read(&root.join("yog.toml")).ok();
2720 let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
2721 let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
2722 let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
2723 for e in &entries {
2724 known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2725 }
2726 for e in &entity_files {
2727 known.add(refs::RefKind::Entity, e.id.clone());
2728 for (wanted, model, what) in [(&e.model, true, "model"), (&e.animations, false, "animations")] {
2730 if let Some(id) = wanted
2731 && mod_ns.is_some_and(|ns| id.starts_with(&format!("{ns}:")))
2732 && !bodies.iter().any(|b| b.model == model && &b.id == id)
2733 {
2734 errors.push(format!("{}: {what} `{id}`: no such file in content/ (`{}`)", e.file, if model { ".ymodel" } else { ".yanim" }));
2735 }
2736 }
2737 }
2738 let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
2741 let mut ores: Vec<OreOut> = Vec::new();
2742 let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
2743 let mut tables: Vec<table::Table> = Vec::new();
2744 let mut loot: Vec<(String, Vec<u8>)> = Vec::new();
2746 for (shown, text) in &pending_layouts {
2747 let mut cx = Ctx {
2748 root,
2749 refs,
2750 file: shown.clone(),
2751 ns: mod_ns.unwrap_or("minecraft").to_string(),
2752 errors: &mut errors,
2753 warnings: &mut warnings,
2754 textures: &mut textures,
2755 inventories: &mut inventories,
2756 deferred: &mut deferred,
2757 };
2758 let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables, &mut loot, &mut out) else { continue };
2759 if recipe_defs.contains_key(&k.id) {
2760 errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
2761 }
2762 recipe_defs.insert(k.id.clone(), k);
2763 }
2764 let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
2766 for (shown, text, path) in &pending_recipes {
2767 let mut cx = Ctx {
2768 root,
2769 refs,
2770 file: shown.clone(),
2771 ns: mod_ns.unwrap_or("minecraft").to_string(),
2772 errors: &mut errors,
2773 warnings: &mut warnings,
2774 textures: &mut textures,
2775 inventories: &mut inventories,
2776 deferred: &mut deferred,
2777 };
2778 if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs) {
2779 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2780 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2781 }
2782 recipes.push(r);
2783 }
2784 }
2785 for r in &recipes {
2786 known.add(refs::RefKind::Recipe, r.id.clone());
2787 }
2788 let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
2790 for (files, feature) in ores {
2791 out.outputs.extend(files);
2792 defs.biome_features.push(feature);
2793 }
2794 for (file, line, path, r) in &deferred {
2796 let found = match r.kind.as_str() {
2797 "level" => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
2798 "ladder" => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
2799 "tool_material" => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
2800 .then_some(())
2801 .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
2802 "armor_material" => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
2803 .then_some(())
2804 .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
2805 "sound_group" => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
2806 .then_some(())
2807 .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
2808 kind if kind.starts_with(TAG_REF) => {
2809 let mut found = kind[TAG_REF.len()..].split('|').map(|k| known.check_tag(k, &r.id));
2811 let first = found.next().unwrap_or(Ok(()));
2812 if first.is_ok() || found.any(|f| f.is_ok()) { Ok(()) } else { first }
2813 }
2814 kind => match refs::RefKind::from_word(kind) {
2815 Some(k) => known.check(k, &r.id),
2816 None => Ok(()),
2817 },
2818 };
2819 if let Err(e) = found {
2820 errors.push(format!("{file}:{line}: {path}: {e}"));
2821 }
2822 }
2823 for e in &entries {
2824 if let Some((level, line)) = &e.block.tool_level {
2825 tool_level_tags(&e.id, level, &defs, &mut out, &mut |m| errors.push(format!("{}:{line}: block.tool_level: {m}", e.file)), &mut |m| warnings.push(format!("{}:{line}: block.tool_level: {m}", e.file)));
2826 }
2827 }
2828 let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
2829 let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
2831 for (shown, text) in &pending_books {
2832 let mut cx = Ctx {
2833 root,
2834 refs,
2835 file: shown.clone(),
2836 ns: mod_ns.unwrap_or("minecraft").to_string(),
2837 errors: &mut errors,
2838 warnings: &mut warnings,
2839 textures: &mut textures,
2840 inventories: &mut inventories,
2841 deferred: &mut deferred,
2842 };
2843 if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
2844 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2845 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2846 }
2847 books.push(b);
2848 }
2849 }
2850 if let Some(ns) = mod_ns {
2853 let screens = inventory_screens(&root.join("src"));
2854 for (file, line, id) in &inventories {
2855 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2856 errors.push(format!(
2857 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2858 ));
2859 }
2860 }
2861 }
2862 for (target, source) in &textures {
2863 match std::fs::read(source) {
2864 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2865 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2866 }
2867 }
2868 out.warnings = warnings;
2869 if !errors.is_empty() {
2870 return Err(format!(
2871 "invalid blocks/items in content/:\n {}",
2872 errors.join("\n ")
2873 ));
2874 }
2875 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2876 out.items = entries.len() - out.blocks;
2877 out.recipes = recipes.len();
2878 out.books = books.len();
2879 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entity_files.is_empty() || !defs.sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !dimensions.is_empty() || !loot.is_empty())
2880 && !mentions_content_macro(&root.join("src"))
2881 {
2882 out.warnings.push(format!(
2883 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2884 CONTENT_DIR
2885 ));
2886 }
2887 out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
2888 out.data_files.extend(bodies.iter().map(|b| (b.path(), b.data.clone())));
2889 out.data_files.extend(entity_files.iter().map(|e| (e.path(), e.data.clone())));
2890 out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
2891 out.data_files.extend(loot.iter().map(|(id, data)| (loot_path(id), data.clone())));
2892 defs.loot_modifiers = loot.into_iter().map(|(id, _)| id).collect();
2893 for m in &defs.tool_materials {
2895 if let Some((n, p)) = m.incorrect.split_once(':')
2896 && n != "minecraft"
2897 {
2898 out.tags.entry(format!("when/any/1.21/data/{n}/tags/block/{p}.json")).or_default();
2899 }
2900 }
2901 defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
2902 defs.tables = tables;
2903 out.source = Some(rust_source(&entries, &recipes, &books, &bodies, &entity_files, &defs));
2904 version_overlays(&mut out, &known.datapack);
2905 out.registrations = registrations(&entries, &entity_files, &dimensions, &defs);
2906 Ok(out)
2907}
2908
2909fn registrations(entries: &[Entry], entities: &[EntityFile], dimensions: &[(String, Vec<u8>)], defs: &Defs) -> String {
2914 fn hash(text: &str) -> String {
2915 let h = text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
2917 format!("{h:016x}")
2918 }
2919 let mut lines = Vec::new();
2920 for e in entries {
2921 let what = match e.kind {
2922 Kind::Block => {
2923 let b = BlockProps { tools: Vec::new(), tool_level: None, ..e.block.clone() };
2925 format!("{b:?} {:?} {:?}", e.item, e.requires)
2926 }
2927 _ => format!("{:?} {:?}", e.item, e.requires),
2928 };
2929 lines.push(format!("{} {} {}", e.kind.word(), e.id, hash(&what)));
2930 }
2931 for e in entities {
2932 lines.push(format!("entity {} {}", e.id, hash(&format!("{:?}", e.data))));
2933 }
2934 for (id, data) in dimensions {
2935 lines.push(format!("dimension {id} {}", hash(&format!("{data:?}"))));
2936 }
2937 for g in &defs.sound_groups {
2938 lines.push(format!("sound_group {} {}", g.id, hash(&format!("{g:?}"))));
2939 }
2940 for m in &defs.tool_materials {
2941 lines.push(format!("tool_material {} {}", m.id, hash(&format!("{m:?}"))));
2942 }
2943 for m in &defs.armor_materials {
2944 lines.push(format!("armor_material {} {}", m.id, hash(&format!("{m:?}"))));
2945 }
2946 lines.sort();
2947 lines.join("\n") + "\n"
2948}
2949
2950fn version_overlays(out: &mut Compiled, datapack: &str) {
2955 fn map(v: &mut Value, version: &str, missing: &mut BTreeSet<String>) -> bool {
2956 match v {
2957 Value::String(s) if s.starts_with("minecraft:") => {
2958 let id = s.trim_start_matches('#').to_string();
2959 let eq = refs::equivalent(refs::RefKind::Item, &id, version).or_else(|| refs::equivalent(refs::RefKind::Block, &id, version));
2960 match eq {
2961 Some(e) if e != id => {
2962 *s = e;
2963 true
2964 }
2965 Some(_) => false,
2966 None => {
2968 if refs::is_vanilla(&id) {
2969 missing.insert(id);
2970 }
2971 false
2972 }
2973 }
2974 }
2975 Value::Array(a) => a.iter_mut().fold(false, |c, x| map(x, version, missing) | c),
2976 Value::Object(o) => o.values_mut().fold(false, |c, x| map(x, version, missing) | c),
2977 _ => false,
2978 }
2979 }
2980 let mut overlays = Vec::new();
2981 for (path, bytes) in &out.outputs {
2982 if !path.starts_with("data/") || !path.ends_with(".json") {
2983 continue;
2984 }
2985 let Ok(json) = serde_json::from_slice::<Value>(bytes) else { continue };
2986 for version in refs::versions().iter().filter(|v| *v != datapack) {
2987 let mut copy = json.clone();
2988 let mut missing = BTreeSet::new();
2989 if map(&mut copy, version, &mut missing) {
2990 overlays.push((format!("when/any/{version}/{path}"), pretty(©)));
2991 }
2992 for id in missing {
2993 out.warnings.push(format!("{path} names {id}, which Minecraft {version} hasn't got"));
2994 }
2995 }
2996 }
2997 out.outputs.extend(overlays);
2998}
2999
3000
3001fn mentions_content_macro(dir: &Path) -> bool {
3003 let Ok(entries) = std::fs::read_dir(dir) else {
3004 return false;
3005 };
3006 entries.flatten().any(|e| {
3007 let p = e.path();
3008 if p.is_dir() {
3009 mentions_content_macro(&p)
3010 } else {
3011 p.extension().is_some_and(|x| x == "rs")
3012 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
3013 }
3014 })
3015}
3016
3017fn rust_ident(path: &str) -> String {
3019 let mut s: String = path
3020 .chars()
3021 .map(|c| {
3022 if c.is_ascii_alphanumeric() {
3023 c.to_ascii_lowercase()
3024 } else {
3025 '_'
3026 }
3027 })
3028 .collect();
3029 if s.starts_with(|c: char| c.is_ascii_digit()) {
3030 s.insert(0, '_');
3031 }
3032 s
3033}
3034
3035fn float(n: f64) -> String {
3036 let s = format!("{n:?}");
3037 if s.contains('.') || s.contains('e') {
3038 s
3039 } else {
3040 format!("{s}.0")
3041 }
3042}
3043
3044fn when_loaded(requires: &[String], stmt: &str) -> String {
3046 if requires.is_empty() {
3047 return stmt.to_string();
3048 }
3049 let cond: Vec<String> = requires.iter().map(|m| format!("::yog_api::mod_loaded({m:?})")).collect();
3050 format!("if {} {{ {stmt} }}", cond.join(" && "))
3051}
3052
3053fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile], bodies: &[BodyFile], entities: &[EntityFile], defs: &Defs) -> String {
3054 let sound_groups = &defs.sound_groups;
3055 let mut code = String::from(
3056 "// <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",
3057 );
3058 if !entities.is_empty() {
3059 code.push_str("\n/// The entity types of `content/` (`.yentity`), by id.\n#[allow(dead_code)]\npub struct Entities;\n\n#[allow(dead_code, clippy::all)]\nimpl Entities {\n");
3060 let mut used: HashMap<String, usize> = HashMap::new();
3061 for e in entities {
3062 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3063 let mut ident = rust_ident(path).to_ascii_uppercase();
3064 let n = used.entry(ident.clone()).or_insert(0);
3065 *n += 1;
3066 if *n > 1 {
3067 let _ = write!(ident, "_{n}");
3068 }
3069 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", e.id);
3070 }
3071 code.push_str("}\n");
3072 }
3073 for (model, set, what) in [(true, "Models", "models (`.ymodel`)"), (false, "Anims", "animations (`.yanim`)")] {
3074 let mine: Vec<&BodyFile> = bodies.iter().filter(|b| b.model == model).collect();
3075 if mine.is_empty() {
3076 continue;
3077 }
3078 let _ = writeln!(code, "\n/// The {what} of `content/`, by id (`yog_api::body::model`/`clip`).\n#[allow(dead_code)]\npub struct {set};\n\n#[allow(dead_code, clippy::all)]\nimpl {set} {{");
3079 let mut used: HashMap<String, usize> = HashMap::new();
3080 for b in mine {
3081 let path = b.id.split_once(':').map_or(b.id.as_str(), |(_, p)| p);
3082 let mut ident = rust_ident(path).to_ascii_uppercase();
3083 let n = used.entry(ident.clone()).or_insert(0);
3084 *n += 1;
3085 if *n > 1 {
3086 let _ = write!(ident, "_{n}");
3087 }
3088 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", b.id);
3089 }
3090 code.push_str("}\n");
3091 }
3092 for (kind, set, def) in [
3093 (Kind::Block, "Blocks", "BlockDef"),
3094 (Kind::Item, "Items", "ItemDef"),
3095 ] {
3096 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
3097 let _ = writeln!(
3098 code,
3099 "\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} {{",
3100 if kind == Kind::Block {
3101 "blocks"
3102 } else {
3103 "items"
3104 }
3105 );
3106 let mut used: HashMap<String, usize> = HashMap::new();
3107 for e in mine {
3108 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3109 let mut ident = rust_ident(path);
3110 let n = used.entry(ident.clone()).or_insert(0);
3111 *n += 1;
3112 if *n > 1 {
3113 let _ = write!(ident, "_{n}");
3114 }
3115 let upper = ident.to_ascii_uppercase();
3116 let ident = if is_keyword(&ident) {
3117 format!("r#{ident}")
3118 } else {
3119 ident
3120 };
3121 let _ = writeln!(
3122 code,
3123 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
3124 e.id, e.file, e.id
3125 );
3126 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
3127 match kind {
3128 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3129 Kind::Block => {
3130 let p = &e.block;
3131 if p.hardness.is_some() || p.resistance.is_some() {
3132 let _ = write!(
3133 b,
3134 "\n .strength({}, {})",
3135 float(p.hardness.unwrap_or(1.5)),
3136 float(p.resistance.unwrap_or(6.0))
3137 );
3138 }
3139 if let Some(s) = &p.sound {
3140 let _ = write!(b, "\n .sound({s:?})");
3141 }
3142 if p.light > 0 {
3143 let _ = write!(b, "\n .light_level({})", p.light);
3144 }
3145 if p.requires_tool {
3146 b.push_str("\n .requires_tool()");
3147 }
3148 if p.no_collision {
3149 b.push_str("\n .no_collision()");
3150 }
3151 if let Some(s) = p.slipperiness {
3152 let _ = write!(b, "\n .slipperiness({})", float(s));
3153 }
3154 if let Some(s) = p.shape {
3155 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
3156 let _ = write!(b, "\n .shape({})", args.join(", "));
3157 }
3158 if p.connects {
3159 b.push_str("\n .connects_to_neighbors()");
3160 }
3161 if !p.connect_groups.is_empty() {
3162 let groups: Vec<String> =
3163 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
3164 let _ =
3165 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
3166 }
3167 if let Some(inv) = &p.inventory {
3168 let _ = write!(b, "\n .inventory({inv:?})");
3169 }
3170 if p.redstone {
3171 b.push_str("\n .redstone_source()");
3172 }
3173 if !p.facing.is_empty() {
3174 let _ = write!(b, "\n .facing({:?}, {:?})", p.facing, p.placement);
3175 }
3176 if p.attach {
3177 b.push_str("\n .attach()");
3178 }
3179 if p.waterlogged {
3180 b.push_str("\n .waterlogged()");
3181 }
3182 for (name, values) in &p.properties {
3183 let vs: Vec<String> = values.iter().map(|v| format!("{v:?}")).collect();
3184 let _ = write!(b, "\n .property({name:?}, &[{}])", vs.join(", "));
3185 }
3186 if let Some(name) = &p.powered {
3187 let _ = write!(b, "\n .powered({name:?})");
3188 }
3189 if let Some(name) = &p.comparator {
3190 let _ = write!(b, "\n .comparator({name:?})");
3191 }
3192 if p.accepts_items {
3193 b.push_str("\n .accepts_items()");
3194 }
3195 }
3196 Kind::Item => b.push_str(&item_props(&e.item)),
3197 }
3198 let _ = writeln!(
3199 code,
3200 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
3201 e.file
3202 );
3203 }
3204 code.push_str("}\n");
3205 }
3206
3207 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");
3208 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
3209 for e in entries {
3210 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3211 let mut ident = rust_ident(path);
3212 let n = used.entry((e.kind, ident.clone())).or_insert(0);
3213 *n += 1;
3214 if *n > 1 {
3215 let _ = write!(ident, "_{n}");
3216 }
3217 let ident = if is_keyword(&ident) {
3218 format!("r#{ident}")
3219 } else {
3220 ident
3221 };
3222 if !e.auto {
3223 continue;
3224 }
3225 let _ = match e.kind {
3226 Kind::Block => {
3227 let item = item_props(&e.item);
3229 let stmt = if item.is_empty() {
3230 format!("registry.register_block(Blocks::{ident}());")
3231 } else {
3232 format!("{{ registry.register_block(Blocks::{ident}()); registry.register_item(::yog_api::ItemDef::new({:?}){}); }}", e.id, item.replace("\n ", ""))
3233 };
3234 writeln!(code, " {}", when_loaded(&e.requires, &stmt))
3235 }
3236 Kind::Item => writeln!(code, " {}", when_loaded(&e.requires, &format!("registry.register_item(Items::{ident}());"))),
3237 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3238 };
3239 }
3240 for g in sound_groups {
3241 let _ = writeln!(
3242 code,
3243 " registry.register_sound_group(::yog_api::SoundGroup::new({:?}).sounds({:?}, {:?}, {:?}, {:?}, {:?}).volume({}).pitch({}));",
3244 g.id,
3245 g.break_sound,
3246 g.step,
3247 g.place,
3248 g.hit,
3249 g.fall,
3250 float(g.volume as f64),
3251 float(g.pitch as f64)
3252 );
3253 }
3254 for id in &defs.dimensions {
3255 let _ = writeln!(
3256 code,
3257 " registry.register_dimension_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3258 dimension_path(id)
3259 );
3260 }
3261 for (feature, biomes, step) in &defs.biome_features {
3262 let _ = writeln!(code, " registry.add_biome_feature({feature:?}, {biomes:?}, {step:?});");
3263 }
3264 for m in &defs.tool_materials {
3265 let _ = writeln!(
3266 code,
3267 " registry.register_tool_material(::yog_api::ToolMaterial {{ id: {:?}.into(), durability: {}, speed: {:?}, attack_damage: {:?}, enchantability: {}, level: {:?}.into(), repair: {:?}.into(), level_index: {}, incorrect: {:?}.into() }});",
3268 m.id, m.durability, m.speed, m.attack_damage, m.enchantability, m.level, m.repair, m.level_index, m.incorrect
3269 );
3270 }
3271 for m in &defs.armor_materials {
3272 let d = m.defense;
3273 let _ = writeln!(
3274 code,
3275 " registry.register_armor_material(::yog_api::ArmorMaterial {{ id: {:?}.into(), durability: {}, defense: ::yog_api::ArmorDefense {{ head: {}, chest: {}, legs: {}, feet: {} }}, enchantability: {}, equip_sound: {:?}.into(), toughness: {:?}, knockback_resistance: {:?}, repair: {:?}.into(), texture: {:?}.into() }});",
3276 m.id, m.durability, d.head, d.chest, d.legs, d.feet, m.enchantability, m.equip_sound, m.toughness, m.knockback_resistance, m.repair, m.texture
3277 );
3278 }
3279 for r in recipes {
3280 let _ = writeln!(code, " {}", when_loaded(&r.requires, &r.registration()));
3281 }
3282 for b in books {
3283 let _ = writeln!(
3284 code,
3285 " registry.register_book_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3286 b.id,
3287 b.path()
3288 );
3289 }
3290 for id in &defs.loot_modifiers {
3291 let _ = writeln!(
3292 code,
3293 " registry.add_loot_modifier_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3294 loot_path(id)
3295 );
3296 }
3297 for e in entities {
3298 let _ = writeln!(
3299 code,
3300 " registry.register_entity_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3301 e.id,
3302 e.path()
3303 );
3304 }
3305 for b in bodies {
3306 let _ = writeln!(
3307 code,
3308 " registry.register_body_{}({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3309 if b.model { "model" } else { "anims" },
3310 b.id,
3311 b.path()
3312 );
3313 }
3314 code.push_str("}\n");
3315 for t in &defs.tables {
3316 code.push_str(&table::rust(t));
3317 }
3318 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entities.is_empty() || !sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !defs.dimensions.is_empty() {
3319 code.push_str(
3320 "\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",
3321 );
3322 }
3323 code
3324}
3325
3326fn is_keyword(s: &str) -> bool {
3327 const KW: &[&str] = &[
3328 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
3329 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
3330 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
3331 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
3332 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
3333 ];
3334 KW.contains(&s)
3335}
3336
3337pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
3340 write_tags(root, compiled)?;
3341 let out = root.join(GENERATED_FILE);
3342 let Some(code) = &compiled.source else {
3343 let _ = std::fs::remove_file(&out);
3344 return Ok(());
3345 };
3346 crate::write_if_changed(&out, code)?;
3347 let dir = out.parent().unwrap_or(root);
3348 for (path, bytes) in &compiled.data_files {
3349 let file = dir.join(path);
3350 if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
3351 if let Some(parent) = file.parent() {
3352 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
3353 }
3354 std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
3355 }
3356 }
3357 Ok(())
3358}
3359
3360pub const TAGS_FILE: &str = ".yog-build/generated/tags.json";
3363
3364fn write_tags(root: &Path, compiled: &Compiled) -> Result<(), String> {
3366 let mut by_kind: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
3367 for (path, entries) in &compiled.tags {
3368 let data = path.find("data/").map_or(path.as_str(), |i| &path[i + 5..]);
3370 let Some((ns, rest)) = data.split_once("/tags/") else { continue };
3371 let Some((kind, tag)) = rest.split_once('/') else { continue };
3372 let tag = tag.trim_end_matches(".json");
3373 by_kind.entry(kind.to_string()).or_default().entry(format!("{ns}:{tag}")).or_default().extend(entries.keys().cloned());
3374 }
3375 let text = serde_json::to_string_pretty(&by_kind).map_err(|e| e.to_string())?;
3376 crate::write_if_changed(&root.join(TAGS_FILE), &text)
3377}
3378
3379#[derive(Default)]
3382pub struct OutputSet {
3383 items: Vec<Option<Output>>,
3384 at: HashMap<String, usize>,
3385}
3386
3387impl OutputSet {
3388 pub fn get(&self, path: &str) -> Option<&[u8]> {
3389 let i = *self.at.get(path)?;
3390 self.items[i].as_ref().map(|(_, b)| b.as_slice())
3391 }
3392
3393 pub fn contains(&self, path: &str) -> bool {
3394 self.at.contains_key(path)
3395 }
3396
3397 pub fn paths(&self) -> impl Iterator<Item = &String> {
3398 self.at.keys()
3399 }
3400
3401 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
3403 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
3404 self.items[i] = None;
3405 }
3406 self.items.push(Some((path, bytes)));
3407 }
3408
3409 pub fn into_vec(self) -> Vec<Output> {
3410 self.items.into_iter().flatten().collect()
3411 }
3412}
3413
3414impl From<Vec<Output>> for OutputSet {
3415 fn from(outputs: Vec<Output>) -> Self {
3416 let mut set = OutputSet::default();
3417 for (path, bytes) in outputs {
3418 set.put(path, bytes);
3419 }
3420 set
3421 }
3422}
3423
3424fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
3427 let read;
3428 let bytes = match outputs.get(path) {
3429 Some(b) => b,
3430 None => match std::fs::read(root.join(path)) {
3431 Ok(b) => {
3432 read = b;
3433 &read
3434 }
3435 Err(_) => return Ok(Map::new()),
3436 },
3437 };
3438 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
3439}
3440
3441pub fn merge_outputs(
3445 root: &Path,
3446 outputs: &mut Vec<Output>,
3447 compiled: Compiled,
3448) -> Result<(), String> {
3449 let mut set = OutputSet::from(std::mem::take(outputs));
3450 let mut errors = Vec::new();
3451 if !compiled.lang_aliases.is_empty() {
3454 let mut lang_files: BTreeSet<String> =
3455 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
3456 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
3457 for ns in dirs.flatten() {
3458 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
3459 for f in files.flatten() {
3460 if let Ok(rel) = f.path().strip_prefix(root) {
3461 let rel = rel.to_string_lossy().replace('\\', "/");
3462 if is_lang_path(&rel) {
3463 lang_files.insert(rel);
3464 }
3465 }
3466 }
3467 }
3468 }
3469 }
3470 for path in lang_files {
3471 let mut map = match json_output(&set, root, &path) {
3472 Ok(m) => m,
3473 Err(e) => {
3474 errors.push(e);
3475 continue;
3476 }
3477 };
3478 let mut changed = false;
3479 for (from, to) in &compiled.lang_aliases {
3480 if let Some(text) = map.get(from).cloned()
3481 && !map.contains_key(to)
3482 {
3483 map.insert(to.clone(), text);
3484 changed = true;
3485 }
3486 }
3487 if changed {
3488 set.put(path, pretty(&Value::Object(map)));
3489 }
3490 }
3491 }
3492 for ((ns, locale), entries) in &compiled.lang {
3493 let path = format!("assets/{ns}/lang/{locale}.json");
3494 let mut map = match json_output(&set, root, &path) {
3495 Ok(m) => m,
3496 Err(e) => {
3497 errors.push(e);
3498 continue;
3499 }
3500 };
3501 for (k, v) in entries {
3502 map.entry(k.clone())
3503 .or_insert_with(|| Value::String(v.clone()));
3504 }
3505 set.put(path, pretty(&Value::Object(map)));
3506 }
3507 for (path, ids) in &compiled.tags {
3508 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
3511 let path = if !set.contains(path) && root.join(&plural).is_file() {
3512 plural
3513 } else {
3514 path.clone()
3515 };
3516 let mut map = match json_output(&set, root, &path) {
3517 Ok(m) => m,
3518 Err(e) => {
3519 errors.push(e);
3520 continue;
3521 }
3522 };
3523 let values = map
3524 .entry("values")
3525 .or_insert_with(|| Value::Array(Vec::new()));
3526 if let Value::Array(list) = values {
3527 for (id, &required) in ids {
3528 let listed = |v: &Value| v.as_str().or_else(|| v.get("id").and_then(Value::as_str)) == Some(id.as_str());
3529 if !list.iter().any(listed) {
3530 list.push(if required { Value::String(id.clone()) } else { serde_json::json!({ "id": id, "required": false }) });
3531 }
3532 }
3533 }
3534 map.entry("replace").or_insert(Value::Bool(false));
3535 set.put(path, pretty(&Value::Object(map)));
3536 }
3537 for (path, bytes) in compiled.outputs {
3538 set.put(path, bytes);
3539 }
3540 *outputs = set.into_vec();
3541 if errors.is_empty() {
3542 Ok(())
3543 } else {
3544 Err(format!(
3545 "merging blocks/items into the mod's files:\n {}",
3546 errors.join("\n ")
3547 ))
3548 }
3549}
3550
3551fn is_lang_path(p: &str) -> bool {
3552 let parts: Vec<&str> = p.split('/').collect();
3553 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
3554}
3555
3556#[cfg(test)]
3557mod tests {
3558 use super::*;
3559
3560 fn project(files: &[(&str, &str)]) -> PathBuf {
3561 use std::sync::atomic::{AtomicUsize, Ordering};
3562 static N: AtomicUsize = AtomicUsize::new(0);
3563 let dir = std::env::temp_dir().join(format!(
3564 "yog-content-test-{}-{}",
3565 std::process::id(),
3566 N.fetch_add(1, Ordering::SeqCst)
3567 ));
3568 let _ = std::fs::remove_dir_all(&dir);
3569 for (path, text) in files {
3570 let p = dir.join(path);
3571 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3572 std::fs::write(p, text).unwrap();
3573 }
3574 dir
3575 }
3576
3577 fn output(c: &Compiled, path: &str) -> Value {
3578 let (_, b) = c
3579 .outputs
3580 .iter()
3581 .find(|(p, _)| p == path)
3582 .unwrap_or_else(|| {
3583 panic!(
3584 "no {path} in {:?}",
3585 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
3586 )
3587 });
3588 serde_json::from_slice(b).unwrap()
3589 }
3590
3591 #[test]
3592 fn compiles_a_cube_block() {
3593 let root = project(&[
3594 ("resources/block/ruby_ore.png", "png"),
3595 ("content/ruby.yitem", r#"{ model: { parent: "generated", textures: { layer0: "minecraft:item/emerald" } } }"#),
3596 (
3597 "content/blocks/ruby_ore.yblock",
3598 r#"{
3599 "name": "Ruby Ore",
3600 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
3601 "drops": { "item": "@item:mymod:ruby", "count": [1, 3] } },
3602 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
3603}"#,
3604 ),
3605 ]);
3606 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3607 assert_eq!(c.blocks, 1);
3608 assert_eq!(
3609 output(&c, "assets/mymod/models/block/ruby_ore.json"),
3610 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
3611 );
3612 assert_eq!(
3613 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
3614 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
3615 );
3616 assert_eq!(
3617 output(&c, "assets/mymod/models/item/ruby_ore.json"),
3618 json!({ "parent": "mymod:block/ruby_ore" })
3619 );
3620 assert_eq!(
3621 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
3622 json!("mymod:item/ruby_ore")
3623 );
3624 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
3625 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
3626 assert_eq!(
3627 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
3628 json!(3)
3629 );
3630 assert!(
3631 c.outputs
3632 .iter()
3633 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
3634 );
3635 let src = c.source.as_ref().unwrap();
3636 assert!(
3637 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
3638 "{src}"
3639 );
3640 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
3641 assert!(src.contains(".requires_tool()"), "{src}");
3642 assert!(
3643 src.contains("registry.register_block(Blocks::ruby_ore());"),
3644 "{src}"
3645 );
3646
3647 let mut outputs = Vec::new();
3648 merge_outputs(&root, &mut outputs, c).unwrap();
3649 let find = |p: &str| -> Value {
3650 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
3651 };
3652 assert_eq!(
3653 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
3654 json!("Ruby Ore")
3655 );
3656 assert_eq!(
3657 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
3658 json!(["mymod:ruby_ore"])
3659 );
3660 assert_eq!(
3661 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
3662 json!(["mymod:ruby_ore"])
3663 );
3664 let _ = std::fs::remove_dir_all(root);
3665 }
3666
3667 #[test]
3668 fn raw_parent_with_elements_gets_a_particle() {
3669 let root = project(&[(
3670 "content/cable.yblock",
3671 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
3672 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
3673 )]);
3674 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3675 let m = output(&c, "assets/mymod/models/block/cable.json");
3676 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3677 let _ = std::fs::remove_dir_all(root);
3678 }
3679
3680 #[test]
3681 fn a_facing_block_turns_to_its_placer() {
3682 let root = project(&[(
3683 "content/screen.yblock",
3684 r##"{ "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } },
3685 "states": { "facing": "horizontal" } }"##,
3686 )]);
3687 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3688 let bs = output(&c, "assets/mymod/blockstates/screen.json");
3689 assert_eq!(bs["variants"]["facing=north"], json!({ "model": "mymod:block/screen" }));
3690 assert_eq!(bs["variants"]["facing=west"]["y"], json!(270));
3691 assert!(c.source.unwrap().contains(".facing(\"horizontal\", \"toward\")"));
3692 let _ = std::fs::remove_dir_all(root);
3693 }
3694
3695 #[test]
3696 fn compiles_elements_shape_and_connect() {
3697 let root = project(&[(
3698 "content/pipe.yblock",
3699 r##"{
3700 "block": { "render_layer": "cutout" },
3701 "model": {
3702 "textures": { "0": "minecraft:block/stone" },
3703 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
3704 "rotation": { "axis": "y", "angle": 22.5 },
3705 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
3706 },
3707 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
3708 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
3709}"##,
3710 )]);
3711 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3712 let m = output(&c, "assets/mymod/models/block/pipe.json");
3713 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3714 assert_eq!(m["render_type"], json!("minecraft:cutout"));
3715 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
3716 assert_eq!(m["parent"], json!("minecraft:block/block"), "shown in a slot and held as a block");
3717 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
3718 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
3719 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
3720 let src = c.source.unwrap();
3721 assert!(
3722 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
3723 "{src}"
3724 );
3725 assert!(src.contains(".connects_to_neighbors()"), "{src}");
3726 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
3727 let _ = std::fs::remove_dir_all(root);
3728 }
3729
3730 #[test]
3731 fn sound_groups_are_the_games_or_a_mods_ydef() {
3732 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3733 let crystal = r#"{ kind: "sound_group", id: "crystal", break: "crystal.break", step: "minecraft:block.glass.step",
3734 place: "crystal.place", hit: "crystal.hit", fall: "crystal.fall", pitch: 1.5 }"#;
3735 let a = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:crystal" }} }}"#);
3736 let b = format!(r#"{{ {cube}, block: {{ sound: "amethyst" }} }}"#);
3737 let c = compile(&project(&[("content/a.yblock", &a), ("content/b.yblock", &b), ("content/crystal.ydef", crystal)]), "mymod", &yres::Refs::default()).unwrap();
3738 let src = c.source.unwrap();
3739 assert!(src.contains(r#"registry.register_sound_group(::yog_api::SoundGroup::new("mymod:crystal").sounds("mymod:crystal.break", "minecraft:block.glass.step", "mymod:crystal.place", "mymod:crystal.hit", "mymod:crystal.fall").volume(1.0).pitch(1.5));"#), "{src}");
3740 assert!(src.contains(r#".sound("mymod:crystal")"#) && src.contains(r#".sound("minecraft:amethyst")"#), "{src}");
3741
3742 let bad = format!(r#"{{ {cube}, block: {{ sound: "crystal" }} }}"#);
3743 let missing = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:nope" }} }}"#);
3744 let e = compile(&project(&[("content/a.yblock", &bad), ("content/b.yblock", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3745 assert!(e.contains("`crystal`: not one of the game's sound groups"), "{e}");
3746 assert!(e.contains("no sound group mymod:nope"), "{e}");
3747 }
3748
3749 #[test]
3750 fn tools_are_tags_and_levels_are_definitions_a_mod_can_add() {
3751 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3752 let mythril = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3753 let block = format!(r#"{{ {cube}, block: {{ tool: ["pickaxe", "@tag:block:mymod:mineable/hammer"], tool_level: "@level:mymod:mythril" }} }}"#);
3754 let c = compile(&project(&[("content/ore.yblock", &block), ("content/mythril.ydef", mythril)]), "mymod", &yres::Refs::default()).unwrap();
3755 for tag in [
3756 "data/minecraft/tags/block/mineable/pickaxe.json",
3757 "data/mymod/tags/block/mineable/hammer.json",
3758 "data/minecraft/tags/block/needs_diamond_tool.json",
3760 "when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json",
3761 "when/fabric/1.20/data/fabric/tags/block/needs_tool_level_4.json",
3762 "when/forge/1.20/data/forge/tags/block/needs_netherite_tool.json",
3763 ] {
3764 assert!(c.tags.get(tag).is_some_and(|ids| ids.contains_key("mymod:ore")), "{tag}: {:?}", c.tags);
3765 }
3766 let iron = format!(r#"{{ {cube}, block: {{ tool: "pickaxe", tool_level: "iron" }} }}"#);
3768 let c = compile(&project(&[("content/ore.yblock", &iron)]), "mymod", &yres::Refs::default()).unwrap();
3769 assert!(c.tags["data/minecraft/tags/block/needs_iron_tool.json"].contains_key("mymod:ore"));
3770 assert_eq!(c.tags.len(), 2);
3771 let bad = format!(r#"{{ {cube}, block: {{ tool: "hammer", tool_level: "mythril" }} }}"#);
3773 let e = compile(&project(&[("content/ore.yblock", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3774 assert!(e.contains("block.tool: `hammer`: one of pickaxe, axe, shovel, hoe — or a tag of the mod's own tools"), "{e}");
3775 assert!(e.contains("block.tool_level: `mythril`: one of wood, stone, iron, diamond, netherite — or a level of the tool ladder"), "{e}");
3776 let volts = r#"{ kind: "ladder", id: "voltage" }"#;
3777 let lv = r#"{ kind: "level", id: "lv", ladder: "@ladder:mymod:voltage" }"#;
3778 let loop_ = r#"{ kind: "level", id: "hv", ladder: "@ladder:mymod:voltage", above: "@level:mymod:nope" }"#;
3779 let wrong = format!(r#"{{ {cube}, block: {{ tool_level: "@level:mymod:lv", drops: {{ item: "@item:mymod:nope" }} }} }}"#);
3780 let e = compile(&project(&[("content/v.ydef", volts), ("content/lv.ydef", lv), ("content/hv.ydef", loop_), ("content/ore.yblock", &wrong)]), "mymod", &yres::Refs::default()).unwrap_err();
3781 assert!(e.contains("level mymod:hv: above mymod:nope, which isn't a level of mymod:voltage"), "{e}");
3782 assert!(e.contains("block.tool_level: mymod:lv is a level of mymod:voltage, not of the tool ladder"), "{e}");
3783 assert!(e.contains("block.drops.item: no item mymod:nope in this mod"), "{e}");
3784 }
3785
3786 #[test]
3787 fn reports_errors_with_lines() {
3788 let root = project(&[(
3790 "content/bad.yblock",
3791 "{\n \"model\": {\n \"textures\": { \"0\": \"minecraft:block/stone\" },\n \"elements\": [ {\n \"from\": [0, 0, 0], \"to\": [16, 16, 99],\n \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n \"faces\": { \"nort\": { \"texture\": \"#1\" } }\n } ]\n },\n \"bogus\": 1\n}",
3792 )]);
3793 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3794 for want in [
3795 "content/bad.yblock:5: model.elements[0].to: 99 is out of range (-16..=32)",
3796 "content/bad.yblock:6: model.elements[0].rotation.axis: unknown `q` — did you mean `x`?",
3797 "content/bad.yblock:7: model.elements[0].faces.nort: unknown field `nort` — did you mean `north`?",
3798 "content/bad.yblock:10: bogus: unknown field `bogus`",
3799 ] {
3800 assert!(e.contains(want), "missing {want}\n{e}");
3801 }
3802 let _ = std::fs::remove_dir_all(root);
3803 let root = project(&[(
3805 "content/bad.yblock",
3806 "{\n model: {\n textures: { \"0\": \"minecraft:block/stone\" },\n elements: [ {\n from: [0, 0, 0], to: [16, 16, 16],\n rotation: { axis: \"x\", angle: 30 },\n faces: { north: { texture: \"#1\" } }\n } ]\n },\n}",
3807 )]);
3808 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3809 for want in [
3810 "content/bad.yblock:6: model.elements[0].rotation.angle: 30 — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees",
3811 "content/bad.yblock:7: model.elements[0].faces.north.texture: `#1`: no such slot in textures",
3812 ] {
3813 assert!(e.contains(want), "missing {want}\n{e}");
3814 }
3815 let _ = std::fs::remove_dir_all(root);
3816 }
3817
3818 #[test]
3819 fn compiles_recipes() {
3820 let root = project(&[
3821 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
3822 (
3823 "content/recipes/ruby_block.yrecipe",
3824 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
3825 ),
3826 (
3827 "content/recipes/ruby.yrecipe",
3828 r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
3829 ),
3830 (
3831 "content/recipes/smelt_ruby.yrecipe",
3832 r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
3833 ),
3834 (
3835 "content/recipes/cut.yrecipe",
3836 r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
3837 ),
3838 ]);
3839 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3840 assert_eq!(c.recipes, 4);
3841 let src = c.source.clone().unwrap();
3842 for want in [
3843 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"#,
3844 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
3845 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
3846 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
3847 r##"group: Some("rubies".into())"##,
3848 ] {
3849 assert!(src.contains(want), "missing {want}\n{src}");
3850 }
3851 let _ = std::fs::remove_dir_all(root);
3852 }
3853
3854 #[test]
3855 fn recipe_mistakes() {
3856 let bad = |recipe: &str| {
3857 let root = project(&[("content/r.yrecipe", recipe)]);
3858 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3859 let _ = std::fs::remove_dir_all(root);
3860 e
3861 };
3862 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
3863 assert!(e.contains("pattern uses 'B'"), "{e}");
3864 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
3865 assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
3866 let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
3867 assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
3868 let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
3869 assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
3870 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
3871 assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
3872 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
3873 assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
3874 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
3875 assert!(e.contains("unknown field `pattern`"), "{e}");
3876 let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
3877 assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
3878 }
3879
3880 #[test]
3881 fn block_inventory_names_a_screen() {
3882 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
3883 let root = project(&[
3884 ("content/bench.yblock", block),
3885 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
3886 ]);
3887 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3888 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
3889 let _ = std::fs::remove_dir_all(root);
3890
3891 let root = project(&[("content/bench.yblock", block)]);
3892 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3893 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
3894 let _ = std::fs::remove_dir_all(root);
3895 }
3896
3897 #[test]
3898 fn compiles_a_book() {
3899 let book = r#"{
3900 "id": "guide",
3901 "name": "@Book.title",
3902 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
3903 "entries": [{
3904 "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
3905 "pages": [
3906 { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
3907 { "type": "spotlight", "item": "@item:mymod:ruby" },
3908 { "type": "entity", "entity": "@entity:minecraft:allay" }
3909 ]
3910 }]
3911 }"#;
3912 let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
3913 let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
3914 let mut refs = yres::Refs::default();
3915 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
3916 let out = compile(&root, "mymod", &refs).unwrap();
3917 assert_eq!(out.books, 1);
3918 let src = out.source.unwrap();
3919 assert!(src.contains(r#"registry.register_book_data("mymod:guide", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/books/mymod__guide.ydata")));"#), "{src}");
3920 assert_eq!(out.data_files[0].0, "books/mymod__guide.ydata");
3922 let data = yog_data::binary::decode(&out.data_files[0].1).unwrap();
3923 let text = yog_data::text::to_string(&data);
3924 assert!(text.contains("mymod.book.title") && text.contains("item.mymod.ruby"), "{text}");
3925 assert!(text.contains("not a ref") && text.contains("i18n"), "{text}");
3926 let read = yog_data::from_value::<yog_book::Book>(&data).unwrap().value;
3927 assert_eq!(read.entries[0].category, "mymod:basics");
3928 assert!(matches!(&read.entries[0].pages[1], yog_book::BookPage::Spotlight { item, .. } if item == "mymod:ruby"), "references become ids");
3929
3930 let bad = r#"{ "id": "x",
3932 "categories": [{ "id": "c", "name": "C" }],
3933 "entries": [{ "id": "e", "name": "E", "category": "c",
3934 "pages": [
3935 { "type": "spotlight", "item": "minecraft:stick" },
3936 { "type": "spotlight", "item": "@item:minecraft:grass" },
3937 { "type": "recipe", "recipe": "@recipe:mymod:nope" },
3938 { "type": "entity", "entity": "@entity:create:train" }
3939 ] }] }"#;
3940 let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3941 assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
3942 assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
3943 assert!(e.contains("minecraft:grass isn't in Minecraft 1.21.1 (the mod's datapack; it's in 1.20.1) — there it's minecraft:short_grass"), "{e}");
3944 assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
3945 assert!(e.contains("[external.create]"), "{e}");
3946
3947 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
3948 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
3949 assert!(e.contains("entries[0].pages[0].type: unknown page type `nope`"), "{e}");
3950 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
3951 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
3952 assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
3953 }
3954
3955 #[test]
3956 fn books_use_the_mods_own_page_types() {
3957 let ty = r#"<Component name="PatternPage" book_page="pattern">
3958 <Property name="name" type="book_text"/>
3959 <Property name="patterns" type="data" default="[]"/>
3960 <BookHeader text="{x:Bind name}"/>
3961 <Drawn drawer="mymod:pattern" h="64" data="{x:Bind patterns}"/>
3962 </Component>"#;
3963 let book = |page: &str| format!(r#"{{ id: "guide", name: "G", categories: [{{ id: "mymod:c", name: "C" }}],
3964 entries: [{{ id: "mymod:e", name: "E", category: "@category:mymod:c", pages: [{page}] }}] }}"#);
3965 let ok = book(r#"{ type: "mymod:pattern", name: "@item:minecraft:stick", patterns: [{ startdir: "EAST", signature: "qaq" }] }"#);
3966 let c = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &ok)]), "mymod", &yres::Refs::default()).unwrap();
3967 let b = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
3968 assert_eq!(b.page_types.len(), 1, "the type goes with the book");
3969 let yog_book::BookPage::Custom(p) = &b.entries[0].pages[0] else { panic!("{:?}", b.entries[0].pages[0]) };
3970 assert_eq!(p.fields.get("name").and_then(|n| n.as_str()), Some("item.minecraft.stick"), "texts resolve as the book's do");
3971 assert!(b.i18n);
3972
3973 let unknown = book(r#"{ type: "mymod:pattern", name: "N", colour: 3 }"#);
3974 let e = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &unknown)]), "mymod", &yres::Refs::default()).unwrap_err();
3975 assert!(e.contains("mymod:pattern has no field `colour` (name, patterns)"), "{e}");
3976 let missing = book(r#"{ type: "mymod:nope" }"#);
3977 let e = compile(&project(&[("content/guide.ybook", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3978 assert!(e.contains("no page type mymod:nope"), "{e}");
3979 }
3980
3981 #[test]
3982 fn tools_and_armor_of_the_games_materials_or_the_mods() {
3983 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/iron_pickaxe" } }"#;
3984 let level = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3985 let tool = r#"{ kind: "tool_material", id: "mythril", durability: 1800, speed: 10, attack_damage: 3, enchantability: 18,
3986 level: "@level:mymod:mythril", repair: "@tag:item:c:ingots" }"#;
3987 let armor = r#"{ kind: "armor_material", id: "mythril", durability: 30, defense: { head: 3, chest: 8, legs: 6, feet: 3 },
3988 enchantability: 18, equip_sound: "minecraft:item.armor.equip_diamond", repair: "@item:minecraft:diamond" }"#;
3989 let pick = format!(r#"{{ {model}, item: {{ tool: {{ kind: "pickaxe", material: "@tool_material:mymod:mythril" }} }} }}"#);
3990 let helm = format!(r#"{{ {model}, item: {{ armor: {{ slot: "head", material: "iron" }} }} }}"#);
3991 let files = [("content/mythril.ydef", level), ("content/mythril_tools.ydef", tool), ("content/mythril_armor.ydef", armor), ("content/pick.yitem", pick.as_str()), ("content/helm.yitem", helm.as_str())];
3992 let c = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap();
3993 let src = c.source.unwrap();
3994 assert!(src.contains(r#".tool("pickaxe", "mymod:mythril", 1.0, -2.8)"#), "the kind's attack: {src}");
3995 assert!(src.contains(r#".armor("head", "minecraft:iron")"#), "{src}");
3996 assert!(src.contains(r##"level: "mymod:mythril".into(), repair: "#c:ingots".into(), level_index: 4, incorrect: "minecraft:incorrect_for_netherite_tool".into()"##), "above diamond: netherite's on both versions: {src}");
3997 assert!(src.contains(r#"registry.register_armor_material(::yog_api::ArmorMaterial { id: "mymod:mythril".into()"#) && src.contains(r#"texture: "mymod:mythril".into()"#), "{src}");
3998
3999 let bad = format!(r#"{{ {model}, item: {{ tool: {{ kind: "spoon", material: "bronze" }} }} }}"#);
4000 let e = compile(&project(&[("content/bad.yitem", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4001 assert!(e.contains("`spoon`: one of pickaxe, axe, shovel, hoe, sword") && e.contains("`bronze`: one of the game's (wood, stone, iron, diamond, gold, netherite)"), "{e}");
4002 let missing = format!(r#"{{ {model}, item: {{ armor: {{ slot: "feet", material: "@armor_material:mymod:nope" }} }} }}"#);
4003 let e = compile(&project(&[("content/m.yitem", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4004 assert!(e.contains("no armor material mymod:nope"), "{e}");
4005 }
4006
4007 #[test]
4008 fn data_naming_renamed_content_gets_each_versions_name() {
4009 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4010 let block = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:short_grass" }} }} }}"#);
4011 let c = compile(&project(&[("content/tuft.yblock", &block)]), "mymod", &yres::Refs::default()).unwrap();
4012 let loot = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| String::from_utf8_lossy(b).to_string());
4013 assert!(loot("data/mymod/loot_table/blocks/tuft.json").is_some_and(|l| l.contains("minecraft:short_grass")), "the datapack's name");
4014 let old = loot("when/any/1.20.1/data/mymod/loot_table/blocks/tuft.json").expect("1.20.1's overlay");
4015 assert!(old.contains("minecraft:grass") && !old.contains("short_grass"), "{old}");
4016 let crafter = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:crafter" }} }} }}"#);
4017 let c = compile(&project(&[("content/c.yblock", &crafter)]), "mymod", &yres::Refs::default()).unwrap();
4018 assert!(c.warnings.iter().any(|w| w.contains("minecraft:crafter, which Minecraft 1.20.1 hasn't got")), "{:?}", c.warnings);
4019 }
4020
4021 #[test]
4022 fn ores_become_features_in_biomes_on_every_loader() {
4023 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4024 let block = format!(r#"{{ {model} }}"#);
4025 let ore = r#"{ kind: "ore", id: "mythril_ore", targets: [{ replaces: "stone", block: "@block:mymod:mythril_ore" },
4026 { replaces: "@block:minecraft:basalt", block: "@block:mymod:mythril_ore" }], size: 6, per_chunk: 4,
4027 height: { min: -48, max: 16, shape: "triangle" } }"#;
4028 let c = compile(&project(&[("content/mythril_ore.yblock", &block), ("content/mythril_ore.ydef", ore)]), "mymod", &yres::Refs::default()).unwrap();
4029 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
4030 let cf = get("data/mymod/worldgen/configured_feature/mythril_ore.json");
4031 assert_eq!(cf["config"]["targets"][0]["target"]["tag"], "minecraft:stone_ore_replaceables");
4032 assert_eq!(cf["config"]["targets"][1]["target"]["block"], "minecraft:basalt");
4033 let pf = get("data/mymod/worldgen/placed_feature/mythril_ore.json");
4034 assert_eq!(pf["placement"][2]["height"]["type"], "minecraft:trapezoid");
4035 assert_eq!(get("when/neoforge/1.21/data/mymod/neoforge/biome_modifier/mythril_ore.json")["type"], "neoforge:add_features");
4036 assert_eq!(get("when/forge/any/data/mymod/forge/biome_modifier/mythril_ore.json")["biomes"], "#minecraft:is_overworld");
4037 let src = c.source.unwrap();
4038 assert!(src.contains(r#"registry.add_biome_feature("mymod:mythril_ore", "minecraft:is_overworld", "underground_ores");"#), "{src}");
4039 let bad = r#"{ kind: "ore", id: "x", targets: [{ replaces: "granite", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4040 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4041 assert!(e.contains("`granite`: one of stone, deepslate, netherrack, end_stone"), "{e}");
4042 let missing = r#"{ kind: "ore", id: "x", targets: [{ replaces: "stone", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4043 let e = compile(&project(&[("content/x.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4044 assert!(e.contains("no block mymod:nope"), "{e}");
4045 }
4046
4047 #[test]
4048 fn a_mod_has_loot_tables_of_its_own() {
4049 let table = r#"{ kind: "loot_table", id: "random_gem", add: [{ item: "@item:minecraft:diamond", count: [1, 3] }] }"#;
4050 let c = compile(&project(&[("content/random_gem.ydef", table)]), "mymod", &yres::Refs::default()).unwrap();
4051 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap());
4052 assert_eq!(get("data/mymod/loot_table/random_gem.json").expect("the table")["pools"], json!([]));
4053 let src = c.source.unwrap();
4054 assert!(src.contains(r#"registry.add_loot_modifier_data("mymod:random_gem""#), "{src}");
4055 let modifier = r#"{ kind: "loot_modifier", id: "more_gems", tables: ["@loot_table:minecraft:chests/simple_dungeon"], add: [{ item: "@item:minecraft:emerald", chance: 0.5 }] }"#;
4056 let c = compile(&project(&[("content/more_gems.ydef", modifier)]), "mymod", &yres::Refs::default()).unwrap();
4057 assert!(c.source.unwrap().contains(r#"registry.add_loot_modifier_data("mymod:more_gems""#));
4058 let missing = r#"{ kind: "loot_modifier", id: "m", tables: ["@loot_table:minecraft:chests/nope"], add: [] }"#;
4059 assert!(compile(&project(&[("content/m.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err().contains("chests/nope"));
4060 let bad = r#"{ kind: "loot_table", id: "minecraft:nope", add: [] }"#;
4061 assert!(compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err().contains("the mod's own"));
4062 }
4063
4064 #[test]
4065 fn a_block_says_how_its_item_stacks() {
4066 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4067 let block = format!(r#"{{ item: {{ stack_size: 16, fire_resistant: true, fuel_ticks: 300 }}, {model} }}"#);
4068 let plain = format!(r#"{{ {model} }}"#);
4069 let c = compile(&project(&[("content/log.yblock", &block), ("content/stone.yblock", &plain)]), "mymod", &yres::Refs::default()).unwrap();
4070 let src = c.source.unwrap();
4071 assert!(src.contains(r#"registry.register_item(::yog_api::ItemDef::new("mymod:log").max_stack(16).fire_resistant().fuel(300));"#), "{src}");
4072 assert!(!src.contains(r#"ItemDef::new("mymod:stone")"#), "no item form of its own: the host's default");
4073 }
4074
4075 #[test]
4076 fn blocks_and_items_join_their_tags() {
4077 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4078 let log = format!(r#"{{ tags: ["minecraft:logs", "edified_logs"], {model} }}"#);
4079 let staff = r#"{ tags: ["staves"], model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } } }"#;
4080 let c = compile(&project(&[("content/log.yblock", &log), ("content/staff.yitem", staff)]), "mymod", &yres::Refs::default()).unwrap();
4081 for t in ["data/minecraft/tags/block/logs.json", "data/minecraft/tags/item/logs.json", "data/mymod/tags/block/edified_logs.json", "data/mymod/tags/item/edified_logs.json"] {
4082 assert!(c.tags[t].contains_key("mymod:log"), "{t}");
4083 }
4084 assert!(c.tags["data/mymod/tags/item/staves.json"].contains_key("mymod:staff"));
4085 assert!(!c.tags.contains_key("data/mymod/tags/block/staves.json"), "an item has no block tag");
4086 }
4087
4088 #[test]
4089 fn trees_are_definitions() {
4090 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4091 let block = format!(r#"{{ {model} }}"#);
4092 let tree = r#"{ kind: "tree", id: "glass_tree", min_clipped_height: 6,
4093 trunk: { blocks: [{ block: "@block:mymod:log", weight: 8 }, { block: "@block:minecraft:oak_log" }], shape: "fancy", base_height: 5, random_a: 5, random_b: 3 },
4094 leaves: { blocks: [{ block: "@block:mymod:leaves" }], shape: "fancy", radius: 1, offset: 5, height: 5 } }"#;
4095 let c = compile(&project(&[("content/log.yblock", &block), ("content/leaves.yblock", &block), ("content/glass_tree.ydef", tree)]), "mymod", &yres::Refs::default()).unwrap();
4096 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
4097 let f = &get("data/mymod/worldgen/configured_feature/glass_tree.json")["config"];
4098 assert_eq!(f["trunk_placer"]["type"], "minecraft:fancy_trunk_placer");
4099 assert_eq!(f["trunk_provider"]["entries"][0]["weight"], 8);
4100 assert_eq!(f["foliage_provider"]["state"]["Name"], "mymod:leaves");
4101 assert_eq!((f["foliage_placer"]["height"].as_u64(), f["minimum_size"]["min_clipped_height"].as_u64()), (Some(5), Some(6)));
4102 let bad = r#"{ kind: "tree", id: "t", trunk: { blocks: [{ block: "@block:mymod:nope" }], shape: "straight", base_height: 4 },
4103 leaves: { blocks: [{ block: "@block:minecraft:oak_leaves" }], shape: "blob", radius: 2 } }"#;
4104 let e = compile(&project(&[("content/t.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4105 assert!(e.contains("no block mymod:nope"), "{e}");
4106 }
4107
4108 #[test]
4109 fn what_a_version_or_loader_hasnt_got_keeps_a_recipe_out_of_it() {
4110 let recipe = |key: &str| format!(r#"{{ type: "shaped", pattern: ["D"], key: {{ D: {key} }}, result: "@item:minecraft:stick" }}"#);
4111 let check = |key: &str| {
4112 let c = compile(&project(&[("content/r.yrecipe", &recipe(key))]), "mymod", &yres::Refs::default());
4113 c.map(|c| c.warnings.iter().filter(|w| w.contains("left out")).cloned().collect::<Vec<_>>().join("\n"))
4114 };
4115 let w = check(r#""@tag:item:minecraft:music_discs""#).unwrap();
4116 assert!(w.contains("left out on Minecraft 1.21.1: #minecraft:music_discs isn't there — @tag:item:yog:music_discs is on every version and loader"), "{w}");
4117 assert_eq!(check(r#""@tag:item:yog:music_discs""#).unwrap(), "");
4118 assert_eq!(check(r#"["@tag:item:c:iron_ingots", "@item:minecraft:iron_ingot"]"#).unwrap(), "", "an alternative that's everywhere");
4119 let w = check(r#""@tag:item:c:iron_ingots""#).unwrap();
4120 assert!(w.contains("left out on forge 1.20.1, neoforge 1.20.1, Minecraft 1.21.1") && w.contains("@tag:item:yog:ingots/iron"), "{w}");
4121 let e = check(r#""@tag:item:c:nope""#).unwrap_err();
4122 assert!(e.contains("no item tag c:nope in Minecraft or its loaders"), "{e}");
4123 let e = check(r#""@tag:item:yog:nope""#).unwrap_err();
4124 assert!(e.contains("no item tag yog:nope among Yog's common tags"), "{e}");
4125 assert!(check(r#""@tag:item:othermod:anything""#).is_ok(), "another mod's tags aren't known here");
4126 let block = r#"{ tags: ["yog:planks", "yog:nope"], model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } } }"#;
4127 let e = compile(&project(&[("content/p.yblock", block)]), "mymod", &yres::Refs::default()).unwrap_err();
4128 assert!(e.contains("tags[1]: no block tag yog:nope") && !e.contains("yog:planks"), "{e}");
4129 }
4130
4131 #[test]
4132 fn tags_and_damage_types_are_definitions() {
4133 let tag = r#"{ kind: "tag", of: "entity", id: "sticky_teleporters", values: ["@entity:minecraft:pig", "@tag:entity:c:bosses"],
4134 optional: ["@entity:create:seat"] }"#;
4135 let logs = r#"{ kind: "tag", of: "block", id: "minecraft:logs", values: ["@block:minecraft:stone"] }"#;
4136 let dmg = r#"{ kind: "damage_type", id: "overcast", scaling: "never", tags: ["minecraft:bypasses_armor", "bypasses_effects"] }"#;
4137 let c = compile(&project(&[("content/a.ydef", tag), ("content/b.ydef", logs), ("content/c.ydef", dmg)]), "mymod", &yres::Refs::default()).unwrap();
4138 let sticky = &c.tags["data/mymod/tags/entity_type/sticky_teleporters.json"];
4139 assert_eq!(sticky.iter().map(|(k, v)| (k.as_str(), *v)).collect::<Vec<_>>(), vec![("#c:bosses", false), ("create:seat", false), ("minecraft:pig", true)], "c:bosses isn't on every loader: where it isn't, the tag still loads");
4140 assert!(c.tags["data/minecraft/tags/block/logs.json"].contains_key("minecraft:stone"));
4141 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
4142 let d = get("data/mymod/damage_type/overcast.json");
4143 assert_eq!((d["message_id"].as_str(), d["scaling"].as_str()), (Some("mymod.overcast"), Some("never")));
4144 for t in ["data/minecraft/tags/damage_type/bypasses_armor.json", "data/minecraft/tags/damage_type/bypasses_effects.json"] {
4145 assert!(c.tags[t].contains_key("mymod:overcast"), "{t}");
4146 }
4147 let bad = r#"{ kind: "tag", of: "entity", id: "x", values: ["@entity:minecraft:nope", "@block:minecraft:stone"] }"#;
4148 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4149 assert!(e.contains("nope") && e.contains("`@block:minecraft:stone` in a tag of entity"), "{e}");
4150 }
4151
4152 #[test]
4153 fn a_dimension_and_its_terrain_go_to_the_runtime_as_data() {
4154 let dim = r#"{ kind: "dimension", id: "flatlands", dimension_type: { has_sky_light: true }, biome: "@biome:minecraft:desert",
4155 terrain: { seed: 3, base_height: 70, noise: [{ scale: 80, amplitude: 6 }], fill: "@block:minecraft:sandstone",
4156 layers: [{ block: "@block:minecraft:sand", depth: 3 }] } }"#;
4157 let c = compile(&project(&[("content/flatlands.ydef", dim)]), "mymod", &yres::Refs::default()).unwrap();
4158 let (path, data) = c.data_files.iter().find(|(p, _)| p.starts_with("dimensions/")).expect("the dimension's data");
4159 assert_eq!(path, "dimensions/mymod__flatlands.ydata");
4160 let v = yog_data::binary::decode(data).unwrap();
4161 assert_eq!(v.get("biome"), Some(&yog_data::Value::from("minecraft:desert")), "a plain id: the hosts read it");
4162 assert_eq!(v.get("generator_type"), Some(&yog_data::Value::from("mymod:flatlands")));
4163 let t = yog_data::read_value::<yog_worldgen::Terrain>(v.get("terrain").unwrap()).unwrap();
4164 assert_eq!((t.base_height, t.fill.as_str()), (70, "minecraft:sandstone"));
4165 let src = c.source.unwrap();
4166 assert!(src.contains(r#"registry.register_dimension_data("mymod:flatlands", include_bytes!("#), "{src}");
4167 let bad = r#"{ kind: "dimension", id: "x", terrain: { fill: "@block:minecraft:nope" } }"#;
4168 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4169 assert!(e.contains("no block minecraft:nope"), "{e}");
4170 }
4171
4172 #[test]
4173 fn content_for_other_mods_is_registered_only_with_them() {
4174 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } }"#;
4175 let item = format!(r#"{{ requires: ["pehkui", "create"], {model} }}"#);
4176 let recipe = r#"{ requires: ["pehkui"], type: "shapeless", ingredients: ["@item:minecraft:stick"], result: "@item:minecraft:stick" }"#;
4177 let c = compile(&project(&[("content/shrink.yitem", &item), ("content/r.yrecipe", recipe)]), "mymod", &yres::Refs::default()).unwrap();
4178 let src = c.source.unwrap();
4179 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") && ::yog_api::mod_loaded("create") { registry.register_item(Items::shrink()); }"#), "{src}");
4180 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") { registry.add_recipe("#), "{src}");
4181 }
4182
4183 #[test]
4184 fn books_lay_out_the_mods_own_recipe_kinds() {
4185 let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
4186 let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
4187 entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
4188 pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
4189 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true } } }"#;
4190 let layout = r#"<Component name="InfusionLayout" recipe_layout="infusion" width="96" height="24">
4191 <RecipeSlot slot="core" x="4" y="4" frame="true"/>
4192 <ItemSlot item="minecraft:beacon" x="40" y="4"/>
4193 <RecipeResult x="76" y="4" frame="true"/>
4194 </Component>"#;
4195 let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book), ("content/infusion.ydef", kind)];
4196 let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
4197 assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion"), "{e}");
4198 let c = compile(&project(&[files[0], files[1], files[2], ("src/ui/infusion.yui", layout)]), "mymod", &yres::Refs::default()).unwrap();
4199 let book = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4200 assert_eq!(book.layouts[0].kinds, ["mymod:infusion"], "the book carries the mod's layouts");
4201 let bad = r#"<Component recipe_layout="create:mixing infusion" width="1" height="1">
4203 <RecipeSlot slot="ring"/><ItemSlot item="minecraft:nope"/></Component>"#;
4204 let e = compile(&project(&[files[2], ("src/ui/bad.yui", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4205 assert!(e.contains("`create:mixing`: one of shaped"), "{e}");
4206 assert!(e.contains("mymod:infusion has no slot `ring` — core"), "{e}");
4207 assert!(e.contains("<ItemSlot item=\"minecraft:nope\">: no item minecraft:nope in Minecraft"), "{e}");
4208 }
4209
4210 #[test]
4211 fn compiles_a_recipe_of_the_mods_own_kind() {
4212 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true }, ring: { max: 2 } }, values: { time: "number" } }"#;
4213 let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
4214 values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
4215 let c = compile(&project(&[("content/infusion.ydef", kind), ("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
4216 let src = c.source.unwrap();
4217 assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
4218 assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
4219
4220 let bad = r##"{ type: "mymod:infusion", slots: { ring: ["@item:minecraft:coal", "@item:minecraft:coal", "@item:minecraft:coal"], nope: ["@item:minecraft:coal"] },
4222 values: { time: "soon", heat: 3 }, result: "@item:minecraft:emerald" }"##;
4223 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4224 for want in ["slots.ring: at most 2 ingredient(s), found 3", "slots.nope: mymod:infusion has no slot `nope` (core, ring)", "slots: mymod:infusion needs `core`", "values.time: a number", "values.heat: mymod:infusion has no value `heat` (time)"] {
4225 assert!(e.contains(want), "{want}: {e}");
4226 }
4227 let undeclared = r#"{ "type": "mymod:infusion", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4228 let e = compile(&project(&[("content/u.yrecipe", undeclared)]), "mymod", &yres::Refs::default()).unwrap_err();
4229 assert!(e.contains("type `mymod:infusion`: no such kind — declare it: content/infusion.ydef"), "{e}");
4230 let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
4231 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4232 assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
4233 let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4235 let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
4236 assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
4237 }
4238
4239 #[test]
4240 fn compiles_an_item_with_resource_refs() {
4241 let root = project(&[(
4242 "content/items/ruby.yitem",
4243 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
4244 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
4245 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
4246 )]);
4247 let mut refs = yres::Refs::default();
4248 refs.texts.insert(
4249 "Items.ruby".into(),
4250 ("item.mymod.ruby".into(), "Ruby".into()),
4251 );
4252 refs.texts.insert(
4253 "Strings.ruby_tip".into(),
4254 ("mymod.ruby_tip".into(), "Shiny".into()),
4255 );
4256 refs.images
4257 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
4258 let c = compile(&root, "mymod", &refs).unwrap();
4259 assert_eq!(
4260 output(&c, "assets/mymod/models/item/ruby.json"),
4261 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
4262 );
4263 let src = c.source.clone().unwrap();
4264 assert!(src.contains(".max_stack(16)"), "{src}");
4265 assert!(
4266 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
4267 "{src}"
4268 );
4269 let mut outputs = vec![(
4271 "assets/mymod/lang/ru_ru.json".to_string(),
4272 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
4273 .as_bytes()
4274 .to_vec(),
4275 )];
4276 merge_outputs(&root, &mut outputs, c).unwrap();
4277 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
4278 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
4279 let _ = std::fs::remove_dir_all(root);
4280 }
4281
4282 #[test]
4283 fn namespace_needed_for_odd_mod_ids() {
4284 let root = project(&[(
4285 "content/x.yitem",
4286 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
4287 )]);
4288 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
4289 assert!(e.contains("give the namespace"), "{e}");
4290 let _ = std::fs::remove_dir_all(root);
4291 }
4292
4293 #[test]
4294 fn body_files_compile_and_register() {
4295 let model = r#"{ "texture": "misc/wings.png", "bones": [
4296 { "name": "wing", "pivot": [5, 0, 0], "cubes": [{ "origin": [-10, 0, 0], "size": [10, 20, 2], "uv": [22, 0] }] } ] }"#;
4297 let anim = r#"{ "spread": { "length": 0.5, "bones": { "wing": { "rotation": { "0": [0, 0, 0], "0.5": [0, 0, -90] } } } } }"#;
4298 let out = compile(&project(&[("content/wings.ymodel", model), ("content/wings.yanim", anim)]), "mymod", &yres::Refs::default()).unwrap();
4299 assert!(out.data_files.iter().any(|(p, _)| p == "body/models/mymod__wings.ydata"));
4300 let src = out.source.unwrap();
4301 assert!(src.contains("registry.register_body_model(\"mymod:wings\""), "{src}");
4302 assert!(src.contains("registry.register_body_anims(\"mymod:wings\""), "{src}");
4303 assert!(src.contains("pub const WINGS: &'static str = \"mymod:wings\";"), "{src}");
4304 let bad = r#"{ "texture": "x.png", "bones": [{ "name": "a", "parent": "b" }] }"#;
4305 let e = compile(&project(&[("content/bad.ymodel", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4306 assert!(e.contains("must come before it"), "{e}");
4307 }
4308
4309 #[test]
4310 fn entities_compile_register_and_name_their_models() {
4311 let model = r#"{ "texture": "entity/wisp.png", "bones": [{ "name": "body", "cubes": [{ "origin": [-2, 0, -2], "size": [4, 4, 4] }] }] }"#;
4312 let wisp = r#"{ "name": "Wisp", "kind": "mob", "width": 0.5, "height": 0.5, "model": "wisp", "goals": ["float", "wander"],
4313 "attributes": { "max_health": 6, "movement_speed": 0.3 } }"#;
4314 let out = compile(&project(&[("content/wisp.ymodel", model), ("content/wisp.yentity", wisp)]), "mymod", &yres::Refs::default()).unwrap();
4315 let src = out.source.as_ref().unwrap();
4316 assert!(src.contains("registry.register_entity_data(\"mymod:wisp\""), "{src}");
4317 assert!(src.contains("pub const WISP: &'static str = \"mymod:wisp\";"), "{src}");
4318 assert!(out.data_files.iter().any(|(p, _)| p == "entities/mymod__wisp.ydata"));
4319 let (_, data) = out.data_files.iter().find(|(p, _)| p == "entities/mymod__wisp.ydata").unwrap();
4320 let def: yog_registry::EntityDef = yog_data::read_bytes(data).unwrap();
4321 assert_eq!((def.kind, def.model.as_deref()), (yog_registry::EntityKind::Mob, Some("mymod:wisp")));
4322 let missing = r#"{ "kind": "object", "width": 1, "height": 1, "model": "nope" }"#;
4323 let e = compile(&project(&[("content/thing.yentity", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4324 assert!(e.contains("model `mymod:nope`: no such file"), "{e}");
4325 }
4326
4327}