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};
36use yog_data::Schema;
37
38pub const CONTENT_DIR: &str = "content";
40pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
42
43pub type Output = (String, Vec<u8>);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum Kind {
49 Block,
51 Item,
53 Recipe,
55 Book,
57 Def,
60 Model,
62 Anim,
64 Entity,
66 Font,
68}
69impl Kind {
70 pub fn of(path: &Path) -> Option<Kind> {
71 match path.extension()?.to_str()? {
72 "yblock" => Some(Kind::Block),
73 "yitem" => Some(Kind::Item),
74 "yrecipe" => Some(Kind::Recipe),
75 "ybook" => Some(Kind::Book),
76 "ydef" => Some(Kind::Def),
77 "ymodel" => Some(Kind::Model),
78 "yanim" => Some(Kind::Anim),
79 "yentity" => Some(Kind::Entity),
80 "yfont" => Some(Kind::Font),
81 _ => None,
82 }
83 }
84
85 fn word(self) -> &'static str {
86 match self {
87 Kind::Block => "block",
88 Kind::Item => "item",
89 Kind::Recipe => "recipe",
90 Kind::Book => "book",
91 Kind::Def => "definition",
92 Kind::Model => "model",
93 Kind::Anim => "animation",
94 Kind::Entity => "entity",
95 Kind::Font => "font",
96 }
97 }
98}
99
100#[derive(Debug, Default)]
102pub struct Compiled {
103 pub outputs: Vec<Output>,
105 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
108 lang_aliases: Vec<(String, String)>,
110 tags: BTreeMap<String, BTreeMap<String, bool>>,
113 pub source: Option<Source>,
115 pub data_files: Vec<(String, Vec<u8>)>,
118 pub warnings: Vec<String>,
119 pub registrations: String,
122 pub blocks: usize,
123 pub items: usize,
124 pub recipes: usize,
125 pub books: usize,
126}
127
128pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
131pub const DISPLAY_SLOTS: [&str; 8] = [
132 "thirdperson_righthand",
133 "thirdperson_lefthand",
134 "firstperson_righthand",
135 "firstperson_lefthand",
136 "gui",
137 "head",
138 "ground",
139 "fixed",
140];
141pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
142pub const RECIPE_TYPES: [&str; 8] =
143 ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
144
145pub struct Template {
147 pub name: &'static str,
148 pub parent: &'static str,
150 pub slots: &'static [&'static str],
152 pub sprite: bool,
154 pub has_particle: bool,
156}
157
158pub const TEMPLATES: &[Template] = &[
159 Template {
160 name: "cube_all",
161 parent: "minecraft:block/cube_all",
162 slots: &["all"],
163 sprite: false,
164 has_particle: true,
165 },
166 Template {
167 name: "cube_column",
168 parent: "minecraft:block/cube_column",
169 slots: &["end", "side"],
170 sprite: false,
171 has_particle: true,
172 },
173 Template {
174 name: "cube_bottom_top",
175 parent: "minecraft:block/cube_bottom_top",
176 slots: &["top", "bottom", "side"],
177 sprite: false,
178 has_particle: true,
179 },
180 Template {
181 name: "cube",
182 parent: "minecraft:block/cube",
183 slots: &["down", "up", "north", "south", "west", "east"],
184 sprite: false,
185 has_particle: false,
186 },
187 Template {
188 name: "orientable",
189 parent: "minecraft:block/orientable",
190 slots: &["front", "side", "top"],
191 sprite: false,
192 has_particle: true,
193 },
194 Template {
195 name: "cross",
196 parent: "minecraft:block/cross",
197 slots: &["cross"],
198 sprite: false,
199 has_particle: true,
200 },
201 Template {
202 name: "generated",
203 parent: "minecraft:item/generated",
204 slots: &["layer0"],
205 sprite: true,
206 has_particle: true,
207 },
208 Template {
209 name: "handheld",
210 parent: "minecraft:item/handheld",
211 slots: &["layer0"],
212 sprite: true,
213 has_particle: true,
214 },
215];
216
217pub fn template(name: &str) -> Option<&'static Template> {
218 TEMPLATES.iter().find(|t| t.name == name)
219}
220
221pub fn valid_namespace(s: &str) -> bool {
223 !s.is_empty()
224 && s.bytes().all(|b| {
225 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
226 })
227}
228
229pub fn valid_path(s: &str) -> bool {
231 !s.is_empty()
232 && !s.starts_with('/')
233 && !s.ends_with('/')
234 && s.bytes().all(|b| {
235 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
236 })
237}
238
239pub fn valid_id(s: &str) -> bool {
241 match s.split_once(':') {
242 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
243 None => valid_path(s),
244 }
245}
246
247pub fn find(root: &Path) -> Vec<PathBuf> {
251 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
252 let Ok(entries) = std::fs::read_dir(dir) else {
253 return;
254 };
255 for entry in entries.flatten() {
256 let path = entry.path();
257 if path.is_dir() {
258 walk(&path, out);
259 } else if Kind::of(&path).is_some() {
260 out.push(path);
261 }
262 }
263 }
264 let mut out = Vec::new();
265 walk(&root.join(CONTENT_DIR), &mut out);
266 out.sort();
267 out
268}
269
270fn display(root: &Path, p: &Path) -> String {
271 p.strip_prefix(root)
272 .unwrap_or(p)
273 .to_string_lossy()
274 .replace('\\', "/")
275}
276
277#[derive(Debug, Clone, Default)]
280struct BlockProps {
281 hardness: Option<f64>,
282 resistance: Option<f64>,
283 sound: Option<String>,
284 light: u8,
285 tools: Vec<String>,
287 tool_level: Option<(String, usize)>,
290 requires_tool: bool,
291 no_collision: bool,
292 slipperiness: Option<f64>,
293 shape: Option<[f64; 6]>,
295 connect_groups: Vec<String>,
296 connects: bool,
297 facing: String,
299 placement: String,
301 attach: bool,
302 waterlogged: bool,
303 properties: Vec<(String, Vec<String>)>,
305 powered: Option<String>,
307 comparator: Option<String>,
308 accepts_items: bool,
310 inventory: Option<String>,
312 redstone: bool,
314 no_occlusion: bool,
316 kind: String,
318 base: Option<String>,
320 set: Option<String>,
321 press_ticks: Option<u32>,
322 mobs_only: bool,
323}
324
325#[derive(Debug, Clone, Default)]
326struct ItemProps {
327 stack_size: Option<u8>,
328 durability: u32,
329 fire_resistant: bool,
330 fuel_ticks: u32,
331 food: Option<(u32, f64, bool)>,
333 tool: Option<(String, String, f32, f32)>,
335 armor: Option<(String, String)>,
337 wearable: Option<String>,
339 hangs: Option<(String, Option<[u32; 2]>)>,
341}
342
343#[derive(Debug, Clone)]
345struct Entry {
346 kind: Kind,
347 id: String,
348 file: String,
349 auto: bool,
350 requires: Vec<String>,
352 block: BlockProps,
353 item: ItemProps,
354 potion: Option<yog_registry::PotionDef>,
355}
356
357struct Ctx<'a> {
359 root: &'a Path,
360 refs: &'a yres::Refs,
361 file: String,
362 ns: String,
363 errors: &'a mut Vec<String>,
364 warnings: &'a mut Vec<String>,
365 textures: &'a mut BTreeMap<String, PathBuf>,
367 inventories: &'a mut Vec<(String, usize, String)>,
370 deferred: &'a mut Vec<(String, usize, String, yog_data::Ref)>,
373}
374
375impl Ctx<'_> {
376 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
377 self.errors.push(format!("{}:{line}: {msg}", self.file));
378 }
379
380 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
381 self.warnings.push(format!("{}:{line}: {msg}", self.file));
382 }
383
384 fn err_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
386 let line = loc(path);
387 self.err(line, format!("{path}: {msg}"));
388 }
389
390 fn warn_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
391 let line = loc(path);
392 self.warn(line, format!("{path}: {msg}"));
393 }
394
395 fn schema_errors(&mut self, errors: yog_data::Errors) {
397 for e in errors.0 {
398 let l = e.at.map_or(1, |a| a.line as usize);
399 if e.path.is_root() {
400 self.err(l, e.message)
401 } else {
402 self.err(l, format!("{}: {}", e.path, e.message))
403 }
404 }
405 }
406
407 fn texture(&mut self, loc: Loc, path: &At, t: &yog_formats::content::Texture) -> Option<String> {
411 let s = match t {
412 yog_formats::content::Texture::Slot(slot) => return Some(format!("#{slot}")),
413 yog_formats::content::Texture::Res(r) => {
414 let name = format!("{}.{}", r.set, r.key);
415 return match self.refs.images.get(&name) {
416 Some(id) => Some(id.clone()),
417 None => {
418 self.err_at(loc, path, format!("`@{name}`: no image `{name}` in resources/*.yres"));
419 None
420 }
421 };
422 }
423 yog_formats::content::Texture::Plain(s) => s.trim().to_string(),
424 };
425 if let Some(stem) = s.strip_suffix(".png") {
426 let rel = stem.trim_start_matches("./").trim_start_matches('/');
427 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
428 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
429 self.err_at(loc, path, format!("`{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"));
430 return None;
431 }
432 let file = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png"));
433 if !file.is_file() {
434 self.err_at(loc, path, format!("resources/{rel}.png doesn't exist"));
435 return None;
436 }
437 let target = format!("assets/{}/textures/{rel}.png", self.ns);
438 if let Some(other) = self.textures.insert(target.clone(), file.clone())
439 && other != file
440 {
441 let shown = display(self.root, &other);
442 self.err_at(loc, path, format!("{target} also comes from {shown}"));
443 }
444 let meta = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png.mcmeta"));
445 if meta.is_file() {
446 self.textures.insert(format!("{target}.mcmeta"), meta);
447 }
448 return Some(format!("{}:{rel}", self.ns));
449 }
450 if !valid_id(&s) {
451 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`"));
452 return None;
453 }
454 let id = if s.contains(':') { s } else { format!("minecraft:{s}") };
455 let (ns, tex) = id.split_once(':').unwrap_or_default();
456 if ns == self.ns {
457 let file = self.root.join("assets").join(ns).join("textures").join(format!("{tex}.png"));
458 let bundled = self.textures.contains_key(&format!("assets/{ns}/textures/{tex}.png")) || self.refs.images.values().any(|v| *v == id);
459 if !file.is_file() && !bundled {
460 self.warn_at(loc, path, format!("texture {id} isn't in assets/{ns}/textures/ or resources/"));
461 }
462 }
463 Some(id)
464 }
465}
466
467type Loc<'a> = &'a dyn Fn(&At) -> usize;
469
470struct Model {
472 json: Value,
473 bounds: Option<[f64; 6]>,
475}
476
477fn nums(ns: &[f64]) -> Value {
478 Value::Array(ns.iter().map(|n| num_value(*n)).collect())
479}
480
481fn model(cx: &mut Ctx, loc: Loc, at: &At, m: &yog_formats::content::Model, item: bool) -> Option<Model> {
483 let mut out = Map::new();
484 let mut tpl: Option<&Template> = None;
485 let mut raw_parent = false;
486 if let Some(name) = &m.parent {
487 if let Some(t) = template(name) {
488 if t.sprite && !item {
489 cx.err_at(loc, &at.key("parent"), format!("{name:?} is an item sprite — block models use cube_all, cube_column, ..."));
490 }
491 out.insert("parent".into(), Value::String(t.parent.into()));
492 tpl = Some(t);
493 } else if name.contains(':') && valid_id(name) {
494 out.insert("parent".into(), Value::String(name.clone()));
495 raw_parent = true;
496 } else {
497 let names: Vec<&str> = TEMPLATES.iter().filter(|t| item || !t.sprite).map(|t| t.name).collect();
498 cx.err_at(loc, &at.key("parent"), format!("{name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")));
499 return None;
500 }
501 }
502
503 let mut slots: Vec<String> = Vec::new();
505 let mut textures = Map::new();
506 for (slot, tex) in &m.textures.0 {
507 let here = at.key("textures").key(slot);
508 if slot.is_empty() || slot.starts_with('#') {
509 cx.err_at(loc, &here, "slot names are plain words (`all`, `side`, `0`)");
510 continue;
511 }
512 if let Some(t) = cx.texture(loc, &here, tex) {
513 textures.insert(slot.clone(), Value::String(t));
514 slots.push(slot.clone());
515 }
516 }
517 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
518 if let Some(t) = tpl {
519 if t.sprite {
520 if !defined("layer0", &textures) {
521 cx.err_at(loc, at, format!("parent {:?} needs textures.layer0", t.name));
522 }
523 for slot in &slots {
524 let n = slot.strip_prefix("layer").and_then(|n| n.parse::<usize>().ok());
525 match n {
526 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
527 Some(_) => cx.err_at(loc, &at.key("textures").key(slot), "without the layer before it"),
528 None if slot == "particle" => {}
529 None => cx.warn_at(loc, &at.key("textures").key(slot), "isn't used by an item sprite (layer0, layer1, ...)"),
530 }
531 }
532 } else {
533 for slot in t.slots {
534 if !defined(slot, &textures) {
535 cx.err_at(loc, at, format!("parent {:?} needs textures.{slot}", t.name));
536 }
537 }
538 }
539 }
540
541 let mut bounds: Option<[f64; 6]> = None;
543 if let Some(els) = &m.elements {
544 if let Some(t) = tpl {
545 cx.err_at(loc, &at.key("elements"), format!("elements replace the shape of parent {:?} — remove one of them", t.name));
546 }
547 let mut elements = Vec::new();
548 for (i, el) in els.iter().enumerate() {
549 if let Some(json) = element(cx, loc, &at.key("elements").index(i), el, &textures, raw_parent) {
550 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
551 for a in 0..3 {
552 b[a] = b[a].min(el.from[a].min(el.to[a]));
553 b[a + 3] = b[a + 3].max(el.from[a].max(el.to[a]));
554 }
555 elements.push(json);
556 }
557 }
558 out.insert("elements".into(), Value::Array(elements));
559 if m.parent.is_none() {
562 out.insert("parent".into(), Value::String("minecraft:block/block".into()));
563 }
564 } else if tpl.is_none() && !raw_parent {
565 cx.err_at(loc, at, "no parent and no elements — give a template (parent: \"cube_all\") or elements");
566 }
567
568 if !textures.contains_key("particle")
572 && !tpl.is_some_and(|t| t.has_particle)
573 && (!raw_parent || m.elements.is_some())
574 && let Some(first) = slots.first()
575 {
576 let v = textures[first].clone();
577 textures.insert("particle".into(), v);
578 }
579 if !textures.is_empty() {
580 out.insert("textures".into(), Value::Object(textures));
581 }
582 if let Some(d) = &m.display {
583 let mut display = Map::new();
584 for (place, t) in d.iter() {
585 let mut o = Map::new();
586 for (key, v) in [("rotation", t.rotation), ("translation", t.translation), ("scale", t.scale)] {
587 if let Some(v) = v {
588 o.insert(key.into(), nums(&v));
589 }
590 }
591 display.insert(place.into(), Value::Object(o));
592 }
593 out.insert("display".into(), Value::Object(display));
594 }
595 if let Some(b) = m.ambient_occlusion {
596 out.insert("ambientocclusion".into(), Value::Bool(b));
597 }
598 if let Some(g) = &m.gui_light {
599 out.insert("gui_light".into(), Value::String(yog_formats::content::word(g)));
600 }
601 Some(Model { json: Value::Object(out), bounds })
602}
603
604fn element(cx: &mut Ctx, loc: Loc, at: &At, el: &yog_formats::content::Element, textures: &Map<String, Value>, raw_parent: bool) -> Option<Value> {
606 let mut out = Map::new();
607 out.insert("from".into(), nums(&el.from));
608 out.insert("to".into(), nums(&el.to));
609 if let Some(r) = &el.rotation {
610 if (r.angle / 22.5).fract() != 0.0 {
611 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));
612 }
613 let mut rot = Map::new();
614 rot.insert("origin".into(), nums(&r.origin.unwrap_or([8.0, 8.0, 8.0])));
615 rot.insert("axis".into(), Value::String(yog_formats::content::word(&r.axis)));
616 rot.insert("angle".into(), num_value(r.angle));
617 if r.rescale {
618 rot.insert("rescale".into(), Value::Bool(true));
619 }
620 out.insert("rotation".into(), Value::Object(rot));
621 }
622 if el.shade == Some(false) {
623 out.insert("shade".into(), Value::Bool(false));
624 }
625 let mut faces = Map::new();
626 match &el.faces {
627 None => cx.warn_at(loc, at, "no faces — it's invisible"),
628 Some(f) => {
629 for (dir, face) in f.iter() {
630 let here = at.key("faces").key(dir);
631 let mut fo = Map::new();
632 let slot = face.texture.strip_prefix('#').unwrap_or(&face.texture);
633 if !textures.contains_key(slot) && !raw_parent {
634 cx.err_at(loc, &here.key("texture"), format!("`#{slot}`: no such slot in textures"));
635 }
636 fo.insert("texture".into(), Value::String(format!("#{slot}")));
637 if let Some(uv) = face.uv {
638 fo.insert("uv".into(), nums(&uv));
639 }
640 match face.rotation {
641 None | Some(0) => {}
642 Some(n @ (90 | 180 | 270)) => {
643 fo.insert("rotation".into(), Value::from(n));
644 }
645 Some(_) => cx.err_at(loc, &here.key("rotation"), "must be 0, 90, 180 or 270"),
646 }
647 if let Some(c) = &face.cullface {
648 fo.insert("cullface".into(), Value::String(yog_formats::content::word(c)));
649 }
650 if let Some(t) = face.tint
651 && t >= 0
652 {
653 fo.insert("tintindex".into(), Value::from(t));
654 }
655 faces.insert(dir.into(), Value::Object(fo));
656 }
657 }
658 }
659 out.insert("faces".into(), Value::Object(faces));
660 Some(Value::Object(out))
661}
662
663fn file_id(cx: &mut Ctx, line: usize, id: Option<&str>, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
665 let raw = id.map(str::to_string).unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
666 let (ns, p) = match raw.split_once(':') {
667 Some((ns, p)) => (ns.to_string(), p.to_string()),
668 None => match mod_ns {
669 Some(ns) => (ns.to_string(), raw.clone()),
670 None => {
671 cx.err(line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: id: \"mynamespace:name\"");
672 return None;
673 }
674 },
675 };
676 if !valid_namespace(&ns) || !valid_path(&p) {
677 cx.err(line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
678 return None;
679 }
680 Some((ns, p))
681}
682
683fn turn(facing: Option<&str>, face: Option<&str>) -> (f64, f64) {
688 let yaw = |f: &str| match f {
689 "east" => 90.0,
690 "south" => 180.0,
691 "west" => 270.0,
692 _ => 0.0,
693 };
694 match (face, facing) {
695 (Some("wall"), Some(f)) => (90.0, yaw(f)),
696 (Some("ceiling"), Some(f)) => (180.0, (yaw(f) + 180.0) % 360.0),
697 (_, Some("up")) => (270.0, 0.0),
698 (_, Some("down")) => (90.0, 0.0),
699 (_, Some(f)) => (0.0, yaw(f)),
700 _ => (0.0, 0.0),
701 }
702}
703
704fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
705 let mut v = Map::new();
706 v.insert("model".into(), Value::String(model.into()));
707 if x != 0.0 {
708 v.insert("x".into(), num_value(x));
709 }
710 if y != 0.0 {
711 v.insert("y".into(), num_value(y));
712 }
713 if uvlock {
714 v.insert("uvlock".into(), Value::Bool(true));
715 }
716 if let Some(w) = weight {
717 v.insert("weight".into(), num_value(w));
718 }
719 Value::Object(v)
720}
721
722fn pretty(v: &Value) -> Vec<u8> {
723 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
724 s.push('\n');
725 s.into_bytes()
726}
727
728fn compile_file(
731 cx: &mut Ctx,
732 text: &str,
733 kind: Kind,
734 path: &Path,
735 mod_ns: Option<&str>,
736 out: &mut Compiled,
737 generated: &mut BTreeMap<String, String>,
738) -> Option<Entry> {
739 let doc = match yog_data::text::parse(text) {
740 Ok(d) => d,
741 Err(e) => {
742 cx.err(e.at.line as usize, e.message);
743 return None;
744 }
745 };
746 let loc_fn = |p: &At| doc.locate(text, p).line as usize;
747 let loc: Loc = &loc_fn;
748 let root = At::root();
749 struct Parts {
750 format: Option<u32>,
751 id: Option<String>,
752 name: Option<yog_data::Text>,
753 tooltip: Option<yog_data::Text>,
754 register: Option<yog_formats::content::Register>,
755 model: Option<yog_formats::content::Model>,
756 item_model: Option<yog_formats::content::Model>,
757 block: Option<yog_formats::content::BlockProps>,
758 states: Option<yog_formats::content::States>,
759 item: Option<yog_formats::content::ItemProps>,
760 block_item: Option<yog_formats::content::BlockItemProps>,
762 requires: Vec<String>,
763 tags: Vec<String>,
764 kind: Option<String>,
766 }
767 let (parts, refs, tag_refs) = match kind {
768 Kind::Item => match yog_data::schema::from_doc::<yog_formats::content::ItemFile>(text, &doc) {
769 Ok(r) => {
770 let f = r.value;
771 (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, kind: None }, r.refs, r.tags)
772 }
773 Err(e) => {
774 cx.schema_errors(e);
775 return None;
776 }
777 },
778 Kind::Block => match yog_data::schema::from_doc::<yog_formats::content::BlockFile>(text, &doc) {
779 Ok(r) => {
780 let f = r.value;
781 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: Some(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, kind: f.kind }, r.refs, r.tags)
782 }
783 Err(e) => {
784 cx.schema_errors(e);
785 return None;
786 }
787 },
788 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity | Kind::Font => unreachable!("only blocks and items get here"),
789 };
790 for f in &refs {
792 cx.deferred.push((cx.file.clone(), loc(&f.path), f.path.to_string(), f.value.clone()));
793 }
794 for f in &tag_refs {
795 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())));
796 }
797 for (i, tag) in parts.tags.iter().enumerate() {
799 let at = root.key("tags").index(i);
800 let of = if kind == Kind::Block { "block|item" } else { "item" };
801 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")))));
802 }
803 if let Some(f) = parts.format
804 && f != 1
805 {
806 cx.err_at(loc, &root.key("format"), "format must be 1 (this yog understands format 1)");
807 }
808 let (ns, p) = file_id(cx, loc(&root.key("id")), parts.id.as_deref(), path, mod_ns)?;
809 cx.ns = ns.clone();
810 let id = format!("{ns}:{p}");
811 let auto = parts.register.unwrap_or_default() == yog_formats::content::Register::Auto;
812
813 let potion_only = parts.item.as_ref().is_some_and(|it| it.potion.is_some());
814 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
816 for (field, text, target) in (!potion_only)
817 .then_some([("name", &parts.name, key.clone()), ("tooltip", &parts.tooltip, format!("{key}.desc"))])
818 .into_iter()
819 .flatten()
820 {
821 let Some(t) = text else { continue };
822 let from = match t {
823 yog_data::Text::Literal(s) => {
824 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(target, s.clone());
825 continue;
826 }
827 yog_data::Text::Lang(k) => k.clone(),
828 yog_data::Text::NameOf(r) => {
829 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
830 format!("{}.{rns}.{}", r.kind, rp.replace('/', "."))
831 }
832 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
833 Some((k, _)) => k.clone(),
834 None => {
835 cx.err_at(loc, &root.key(field), format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key));
836 continue;
837 }
838 },
839 };
840 if from != target {
841 out.lang_aliases.push((from, target));
842 }
843 }
844
845 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
846 let full = format!("assets/{ns}/{rel}");
847 if cx.root.join(&full).is_file() {
848 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
849 }
850 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
851 cx.err(1, format!("{full} is also written by {other}"));
852 }
853 out.outputs.push((full, pretty(value)));
854 };
855
856 let potion = parts.item.as_ref().and_then(|it| it.potion.as_ref()).map(|p| yog_registry::PotionDef {
857 id: id.clone(),
858 name: p.name.clone(),
859 effects: p.effects.clone(),
860 brewed: Vec::new(),
861 });
862 let mut entry = Entry {
863 kind,
864 id: id.clone(),
865 file: cx.file.clone(),
866 auto,
867 requires: parts.requires.clone(),
868 block: BlockProps::default(),
869 item: ItemProps::default(),
870 potion,
871 };
872
873 for tag in &parts.tags {
875 let tag = full_id(tag.trim_start_matches('#'), &cx.ns);
876 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", &tag));
877 if kind == Kind::Block {
878 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone(), true);
879 }
880 out.tags.entry(format!("data/{tns}/tags/item/{tpath}.json")).or_default().insert(id.clone(), true);
881 }
882
883 if let Some(it) = &parts.block_item {
885 entry.item.stack_size = it.stack_size;
886 entry.item.fire_resistant = it.fire_resistant;
887 entry.item.fuel_ticks = it.fuel_ticks.unwrap_or(0);
888 }
889
890 if kind == Kind::Item {
891 if let Some(it) = &parts.item {
892 let props = &mut entry.item;
893 props.stack_size = it.stack_size;
894 props.durability = it.durability.unwrap_or(0);
895 props.fire_resistant = it.fire_resistant;
896 props.fuel_ticks = it.fuel_ticks.unwrap_or(0);
897 props.food = it.food.as_ref().map(|f| (f.nutrition, f.saturation, f.always_edible));
898 props.tool = it.tool.as_ref().map(|t| {
899 let (d, s) = t.attack();
900 (t.kind.clone(), t.material.clone(), d, s)
901 });
902 props.armor = it.armor.as_ref().map(|a| (yog_formats::content::word(&a.slot), a.material.clone()));
903 props.wearable = it.wearable.as_ref().map(yog_formats::content::word);
904 props.hangs = it.hangs.as_ref().map(|h| (h.entity.clone(), h.size));
905 if it.hangs.as_ref().and_then(|h| h.size).is_some_and(|[w, h]| w == 0 || h == 0) {
906 cx.err_at(loc, &root.key("item").key("hangs").key("size"), "a hanging thing is at least a block wide and high");
907 }
908 if props.wearable.is_some() && props.armor.is_some() {
909 cx.err_at(loc, &root.key("item").key("wearable"), "armor is worn already — leave wearable out");
910 }
911 if props.tool.is_some() && props.armor.is_some() {
912 cx.err_at(loc, &root.key("item").key("armor"), "an item is a tool or armor, not both");
913 }
914 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
915 cx.warn_at(loc, &root.key("item").key("stack_size"), "ignored: an item with durability stacks to 1");
916 }
917 }
918 if entry.potion.is_some() {
919 if let Some(item_model) = parts.model.as_ref() {
920 let m = model(cx, loc, &root.key("model"), item_model, true)?;
921 emit(cx, format!("models/item/{p}.json"), &m.json);
922 }
923 return Some(entry);
924 }
925 let Some(item_model) = parts.model.as_ref() else {
926 cx.err_at(loc, &root.key("model"), "an item without potion effects needs a model");
927 return None;
928 };
929 let m = model(cx, loc, &root.key("model"), item_model, true)?;
930 emit(cx, format!("models/item/{p}.json"), &m.json);
931 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
932 return Some(entry);
933 }
934
935 let b = parts.block.clone().unwrap_or_default();
937 let bat = root.key("block");
938 let kind_of = parts.kind.as_deref().and_then(yog_formats::content::BlockKind::built_in);
939 let props = &mut entry.block;
940 props.hardness = b.hardness;
941 props.resistance = b.resistance;
942 props.sound = b.sound.as_ref().map(yog_formats::content::word);
943 props.light = b.light.unwrap_or(0);
944 props.tools = b.tool.iter().map(|t| t.0.clone()).collect();
945 props.tool_level = b.tool_level.clone().map(|l| (l, loc(&bat.key("tool_level"))));
946 props.requires_tool = b.requires_tool;
947 props.no_collision = !b.collision.or(kind_of.and_then(|k| k.collision)).unwrap_or(true);
948 props.no_occlusion = !b.occludes.or(kind_of.and_then(|k| k.occludes)).unwrap_or(true);
949 props.slipperiness = b.slipperiness;
950 props.redstone = b.redstone;
951 props.accepts_items = b.accepts_items;
952 props.connect_groups = b.connect_groups.clone();
953 let shape_auto = matches!(b.shape, None | Some(yog_formats::content::BlockShape::Auto));
954 if let Some(yog_formats::content::BlockShape::Box(bx)) = b.shape {
955 props.shape = Some(bx);
956 }
957 if let Some(inv) = &b.inventory {
958 if valid_id(inv) && inv.contains(':') {
959 props.inventory = Some(inv.clone());
960 let at = (cx.file.clone(), loc(&bat.key("inventory")), inv.clone());
961 cx.inventories.push(at);
962 } else {
963 cx.err_at(loc, &bat.key("inventory"), format!("`{inv}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
964 }
965 }
966 if props.tool_level.is_some() && props.tools.is_empty() {
967 cx.warn_at(loc, &bat, "tool_level without tool — any tool of that level mines it");
968 }
969 if let Some(k) = kind_of {
971 props.kind = k.name.clone();
972 props.base = b.base.clone();
973 props.set = b.set.clone();
974 props.press_ticks = b.press_ticks;
975 props.mobs_only = b.triggered_by == Some(yog_formats::content::TriggeredBy::Mobs);
976 let own = [
977 ("shape", b.shape.is_some()),
978 ("connect_groups", !b.connect_groups.is_empty()),
979 ("inventory", b.inventory.is_some()),
980 ("redstone", b.redstone),
981 ("powered", b.powered.is_some()),
982 ("comparator", b.comparator.is_some()),
983 ("accepts_items", b.accepts_items),
984 ];
985 for (field, _) in own.iter().filter(|(_, given)| *given) {
986 cx.err_at(loc, &bat.key(field), format!("{field} doesn't go with kind {} (the game's block)", k.name));
987 }
988 if parts.states.is_some() {
989 cx.err_at(loc, &root.key("states"), format!("kind {} has the game's states — leave states out", k.name));
990 }
991 if k.takes("base") && b.base.is_none() {
992 cx.err_at(loc, &bat, format!("kind {} is made from a block — base: \"@block:mymod:planks\"", k.name));
993 }
994 }
995 let kind_fields = [("base", b.base.is_some()), ("set", b.set.is_some()), ("press_ticks", b.press_ticks.is_some()), ("triggered_by", b.triggered_by.is_some())];
996 for (field, _) in kind_fields.iter().filter(|(_, given)| *given) {
997 match kind_of {
998 Some(k) if k.takes(field) => {}
999 Some(k) => cx.err_at(loc, &bat.key(field), format!("kind {} doesn't take {field}", k.name)),
1000 None => cx.err_at(loc, &bat.key(field), "only a block of a kind takes it (kind: \"stairs\", \"door\"…)"),
1001 }
1002 }
1003 let drops = match &b.drops {
1004 None | Some(yog_formats::content::Drops::Itself) => match kind_of {
1005 Some(k) => kind_drops(&id, k),
1006 None => drops_table(&id, None),
1007 },
1008 Some(yog_formats::content::Drops::Nothing) => Value::Null,
1009 Some(yog_formats::content::Drops::Item(d)) => drops_table(d.item.as_deref().unwrap_or(&id), d.count),
1010 };
1011 let render_layer = b.render_layer.as_ref().map(yog_formats::content::word);
1012
1013 let item_parent = if let Some(k) = kind_of {
1015 kind_models(cx, loc, k, parts.model.as_ref().expect("blocks always have a model"), b.base.as_deref(), render_layer.as_deref(), &ns, &p, &mut emit)
1016 } else {
1017 let mut m = model(cx, loc, &root.key("model"), parts.model.as_ref().expect("blocks always have a model"), false)?;
1018 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1019 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
1021 }
1022 if shape_auto {
1023 entry.block.shape = m
1024 .bounds
1025 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
1026 .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)]);
1027 }
1028 let main = format!("{ns}:block/{p}");
1029 emit(cx, format!("models/block/{p}.json"), &m.json);
1030
1031 let mut rotations: Vec<Value> = Vec::new();
1033 let mut arm: Option<(String, bool)> = None;
1034 if let Some(s) = &parts.states {
1035 let sat = root.key("states");
1036 if let Some(rs) = &s.rotations {
1037 if rs.is_empty() {
1038 cx.err_at(loc, &sat.key("rotations"), "a non-empty list of { x: 0, y: 90, weight: 1, uvlock: false }");
1039 }
1040 for (i, r) in rs.iter().enumerate() {
1041 let here = sat.key("rotations").index(i);
1042 let mut angle = |k: &str, v: Option<u32>| match v {
1043 None => 0.0,
1044 Some(n @ (0 | 90 | 180 | 270)) => n as f64,
1045 Some(_) => {
1046 cx.err_at(loc, &here.key(k), "must be 0, 90, 180 or 270");
1047 0.0
1048 }
1049 };
1050 let (x, y) = (angle("x", r.x), angle("y", r.y));
1051 rotations.push(variant(&main, x, y, r.uvlock, r.weight));
1052 }
1053 }
1054 if let Some(c) = &s.connect {
1055 if let Some(am) = model(cx, loc, &sat.key("connect").key("arm"), &c.arm, false) {
1056 let mut json = am.json;
1057 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json) {
1058 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
1059 }
1060 emit(cx, format!("models/block/{p}_arm.json"), &json);
1061 arm = Some((format!("{ns}:block/{p}_arm"), c.uvlock));
1062 }
1063 entry.block.connects = true;
1064 if entry.block.connect_groups.is_empty() {
1065 entry.block.connect_groups.push(id.clone());
1066 }
1067 }
1068 if s.facing.is_some() || s.attach || s.waterlogged || !s.properties.0.is_empty() {
1069 if s.rotations.is_some() || s.connect.is_some() {
1070 cx.err_at(loc, &sat, "facing, attach, waterlogged and properties can't go with rotations or connect");
1071 }
1072 let b = &mut entry.block;
1073 use yog_formats::content::{Facing, Placement};
1074 if s.attach {
1075 if s.facing == Some(Facing::All) {
1076 cx.err_at(loc, &sat.key("facing"), "an attached block turns four ways — leave facing out");
1077 }
1078 b.attach = true;
1079 b.facing = "horizontal".into();
1080 } else if let Some(f) = s.facing {
1081 b.facing = if f == Facing::All { "all" } else { "horizontal" }.into();
1082 }
1083 if !b.facing.is_empty() {
1084 b.placement = if s.placement == Some(Placement::Away) { "away" } else { "toward" }.into();
1085 } else if s.placement.is_some() {
1086 cx.err_at(loc, &sat.key("placement"), "placement needs facing (or attach)");
1087 }
1088 b.waterlogged = s.waterlogged;
1089 for (name, values) in &s.properties.0 {
1090 let here = sat.key("properties").key(name);
1091 let word = |w: &str| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1092 if !word(name) {
1093 cx.err_at(loc, &here, format!("`{name}`: a property's name is lowercase letters, digits and _"));
1094 } else if ["facing", "face", "waterlogged"].contains(&name.as_str()) {
1095 cx.err_at(loc, &here, format!("`{name}` comes from facing, attach or waterlogged — name yours otherwise"));
1096 }
1097 let mut seen = std::collections::HashSet::new();
1098 if values.len() < 2 || !values.iter().all(|v| word(v) && seen.insert(v.as_str())) {
1099 cx.err_at(loc, &here, "two or more different values, lowercase letters, digits and _");
1100 }
1101 b.properties.push((name.clone(), values.clone()));
1102 }
1103 } else if s.placement.is_some() || !s.models.0.is_empty() || !s.variants.0.is_empty() {
1104 cx.err_at(loc, &sat, "placement, models and variants need facing, attach, waterlogged or properties");
1105 }
1106 }
1107 let states: Vec<(String, Vec<String>)> = {
1110 let b = &entry.block;
1111 let words = |ws: &[&str]| ws.iter().map(|w| w.to_string()).collect::<Vec<_>>();
1112 let mut all = Vec::new();
1113 match b.facing.as_str() {
1114 "all" => all.push(("facing".to_string(), words(&["north", "east", "south", "west", "up", "down"]))),
1115 "horizontal" => all.push(("facing".to_string(), words(&["north", "east", "south", "west"]))),
1116 _ => {}
1117 }
1118 if b.attach {
1119 all.push(("face".to_string(), words(&["floor", "wall", "ceiling"])));
1120 }
1121 if b.waterlogged {
1122 all.push(("waterlogged".to_string(), words(&["false", "true"])));
1123 }
1124 all.extend(b.properties.iter().cloned());
1125 all.sort_by(|a, b| a.0.cmp(&b.0));
1126 all
1127 };
1128 let yes_no = |name: &str| states.iter().any(|(n, v)| n == name && *v == ["false", "true"]);
1130 for (key, value) in [("powered", &b.powered), ("comparator", &b.comparator)] {
1131 if let Some(name) = value {
1132 if yes_no(name) {
1133 if key == "powered" {
1134 entry.block.powered = Some(name.clone());
1135 } else {
1136 entry.block.comparator = Some(name.clone());
1137 }
1138 } else {
1139 cx.err_at(loc, &bat.key(key), format!("`{name}`: a yes/no property of states.properties ([\"false\", \"true\"])"));
1140 }
1141 }
1142 }
1143 let mut rules: Vec<(Vec<(String, String)>, Vec<String>)> = Vec::new();
1145 if let Some(s) = &parts.states {
1146 let sat = root.key("states");
1147 let mut named = std::collections::HashMap::new();
1148 for (name, m) in &s.models.0 {
1149 if let Some(mut m) = model(cx, loc, &sat.key("models").key(name), m, false) {
1150 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1151 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
1152 }
1153 emit(cx, format!("models/block/{p}/{name}.json"), &m.json);
1154 named.insert(name.clone(), format!("{ns}:block/{p}/{name}"));
1155 }
1156 }
1157 for (key, names) in &s.variants.0 {
1158 let here = sat.key("variants").key(key);
1159 let mut when = Vec::new();
1160 for pair in key.split(',').filter(|p| !p.is_empty()) {
1161 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1162 if states.iter().any(|(n, vs)| n == k && vs.iter().any(|x| x == v)) {
1163 when.push((k.to_string(), v.to_string()));
1164 } else {
1165 cx.err_at(loc, &here, format!("`{pair}`: no such property and value"));
1166 }
1167 }
1168 let mut models = Vec::new();
1169 for name in &names.0 {
1170 match named.get(name) {
1171 Some(m) => models.push(m.clone()),
1172 None => cx.err_at(loc, &here, format!("`{name}`: not in states.models")),
1173 }
1174 }
1175 if names.0.is_empty() {
1176 cx.err_at(loc, &here, "a model name, or a list of them");
1177 } else if !models.is_empty() {
1178 rules.push((when, models));
1179 }
1180 }
1181 }
1182 let base: Value = match rotations.len() {
1183 0 => variant(&main, 0.0, 0.0, false, None),
1184 1 => rotations.remove(0),
1185 _ => Value::Array(rotations),
1186 };
1187 let uvlock = parts.states.as_ref().is_some_and(|s| s.uvlock);
1188 let blockstate = match &arm {
1189 None if !states.is_empty() => {
1191 let mut variants = Map::new();
1192 let mut combo: Vec<usize> = vec![0; states.len()];
1193 loop {
1194 let at: Vec<(&str, &str)> = states.iter().zip(&combo).map(|((n, vs), &i)| (n.as_str(), vs[i].as_str())).collect();
1195 let value = |k: &str| at.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
1196 let models = rules
1197 .iter()
1198 .find(|(when, _)| when.iter().all(|(k, v)| value(k) == Some(v.as_str())))
1199 .map_or(vec![main.clone()], |(_, m)| m.clone());
1200 let (x, y) = turn(value("facing"), value("face"));
1201 let key: Vec<String> = at.iter().map(|(n, v)| format!("{n}={v}")).collect();
1202 let picks: Vec<Value> = models.iter().map(|m| variant(m, x, y, uvlock, None)).collect();
1203 let v = if picks.len() == 1 { picks.into_iter().next().unwrap() } else { Value::Array(picks) };
1204 variants.insert(key.join(","), v);
1205 let mut i = states.len();
1207 loop {
1208 if i == 0 {
1209 break;
1210 }
1211 i -= 1;
1212 combo[i] += 1;
1213 if combo[i] < states[i].1.len() {
1214 break;
1215 }
1216 combo[i] = 0;
1217 }
1218 if combo.iter().all(|&c| c == 0) {
1219 break;
1220 }
1221 }
1222 json!({ "variants": variants })
1223 }
1224 None => json!({ "variants": { "": base } }),
1225 Some((arm, uvlock)) => {
1226 let mut parts = vec![json!({ "apply": base })];
1227 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)] {
1228 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1229 }
1230 json!({ "multipart": parts })
1231 }
1232 };
1233 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1234 Some(main)
1235 };
1236
1237 let item_model = match (&parts.item_model, item_parent) {
1239 (None, Some(parent)) => Some(json!({ "parent": parent })),
1240 (None, None) => {
1241 cx.err_at(loc, &root, format!("kind {} has no item model of its own — give item_model", parts.kind.as_deref().unwrap_or_default()));
1242 None
1243 }
1244 (Some(im), _) => model(cx, loc, &root.key("item_model"), im, true).map(|m| m.json),
1245 };
1246 if let Some(im) = item_model {
1247 emit(cx, format!("models/item/{p}.json"), &im);
1248 }
1249 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
1250
1251 if !drops.is_null() {
1252 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1253 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1254 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1255 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set drops: \"none\""));
1256 }
1257 out.outputs.push((full, pretty(&drops)));
1258 }
1259 for tag in &entry.block.tools {
1262 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", tag));
1263 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone(), true);
1264 }
1265 Some(entry)
1266}
1267
1268fn drops_table(item: &str, count: Option<yog_formats::content::DropCount>) -> Value {
1270 let mut entry = json!({ "type": "minecraft:item", "name": item });
1271 if let Some(c) = count {
1272 let count = match c {
1273 yog_formats::content::DropCount::Exactly(n) => num_value(n),
1274 yog_formats::content::DropCount::Between(min, max) => json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) }),
1275 };
1276 entry["functions"] = json!([{ "function": "minecraft:set_count", "count": count }]);
1277 }
1278 json!({
1279 "type": "minecraft:block",
1280 "pools": [{
1281 "rolls": 1,
1282 "entries": [entry],
1283 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1284 }]
1285 })
1286}
1287
1288#[allow(clippy::too_many_arguments)]
1292fn kind_models(
1293 cx: &mut Ctx,
1294 loc: Loc,
1295 k: &yog_formats::content::BlockKind,
1296 m: &yog_formats::content::Model,
1297 base: Option<&str>,
1298 render_layer: Option<&str>,
1299 ns: &str,
1300 p: &str,
1301 emit: &mut dyn FnMut(&mut Ctx, String, &Value),
1302) -> Option<String> {
1303 let at = At::root().key("model");
1304 if m.parent.is_some() || m.elements.is_some() || m.display.is_some() || m.ambient_occlusion.is_some() || m.gui_light.is_some() {
1305 cx.err_at(loc, &at, format!("kind {} has the game's models — give only textures ({})", k.name, k.slots.join(", ")));
1306 }
1307 let mut textures = Map::new();
1308 for (slot, tex) in &m.textures.0 {
1309 let here = at.key("textures").key(slot);
1310 if slot != "particle" && !k.slots.contains(slot) {
1311 cx.warn_at(loc, &here, format!("kind {} has no slot `{slot}` ({})", k.name, k.slots.join(", ")));
1312 }
1313 if let Some(t) = cx.texture(loc, &here, tex) {
1314 textures.insert(slot.clone(), Value::String(t));
1315 }
1316 }
1317 for slot in k.slots.iter().filter(|s| !textures.contains_key(*s)) {
1318 cx.err_at(loc, &at, format!("kind {} needs textures.{slot}", k.name));
1319 }
1320 let path = |name: &str| if name.is_empty() { p.to_string() } else { format!("{p}_{name}") };
1322 let id = |name: &str| match (name, base) {
1323 ("base", Some(b)) => {
1324 let (bns, bp) = b.split_once(':').unwrap_or(("minecraft", b));
1325 format!("{bns}:block/{bp}")
1326 }
1327 _ => format!("{ns}:block/{}", path(name)),
1328 };
1329 for (name, parent) in &k.models.0 {
1330 let mut json = json!({ "parent": parent, "textures": textures });
1331 if let (Some(layer), Value::Object(o)) = (render_layer, &mut json) {
1332 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
1333 }
1334 emit(cx, format!("models/block/{}.json", path(name)), &json);
1335 }
1336 let shown = |km: &yog_formats::content::KindModel| variant(&id(&km.model), km.x.unwrap_or(0) as f64, km.y.unwrap_or(0) as f64, km.uvlock, None);
1337 let blockstate = if k.multipart.is_empty() {
1338 let variants: Map<String, Value> = k.variants.0.iter().map(|(state, km)| (state.clone(), shown(km))).collect();
1339 json!({ "variants": variants })
1340 } else {
1341 let parts: Vec<Value> = k
1342 .multipart
1343 .iter()
1344 .map(|part| {
1345 let mut o = Map::new();
1346 o.insert("apply".into(), shown(&part.apply));
1347 if let Some(when) = &part.when {
1348 o.insert("when".into(), Value::Object(state_properties(when)));
1349 }
1350 Value::Object(o)
1351 })
1352 .collect();
1353 json!({ "multipart": parts })
1354 };
1355 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1356 k.item.as_deref().map(id)
1357}
1358
1359fn state_properties(state: &str) -> Map<String, Value> {
1361 state
1362 .split(',')
1363 .filter_map(|pair| pair.split_once('='))
1364 .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
1365 .collect()
1366}
1367
1368fn kind_drops(id: &str, k: &yog_formats::content::BlockKind) -> Value {
1371 let mut table = drops_table(id, None);
1372 let when = |state: &str| json!({ "condition": "minecraft:block_state_property", "block": id, "properties": state_properties(state) });
1373 let pool = &mut table["pools"][0];
1374 if let Some(state) = &k.drops_when
1375 && let Some(conditions) = pool["conditions"].as_array_mut()
1376 {
1377 conditions.insert(0, when(state));
1378 }
1379 if let Some(state) = &k.drops_two_when {
1380 pool.as_object_mut().map(|o| o.remove("conditions"));
1382 pool["entries"][0]["functions"] = json!([
1383 { "function": "minecraft:set_count", "count": 2, "conditions": [when(state)] },
1384 { "function": "minecraft:explosion_decay" }
1385 ]);
1386 }
1387 table
1388}
1389
1390#[derive(Debug, Clone)]
1394enum RecipeKind {
1395 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1398 Shapeless(Vec<Vec<String>>),
1400 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1402 Stonecutting(Vec<String>),
1403 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1404 Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
1408}
1409
1410struct BookFile {
1412 id: String,
1413 data: Vec<u8>,
1416}
1417
1418struct EntityFile {
1420 id: String,
1421 file: String,
1422 model: Option<String>,
1424 animations: Option<String>,
1425 data: Vec<u8>,
1427}
1428
1429impl EntityFile {
1430 fn path(&self) -> String {
1431 format!("entities/{}.ydata", self.id.replace([':', '/'], "__"))
1432 }
1433}
1434
1435fn compile_entity(cx: &mut Ctx, text: &str, path: &Path, mod_ns: Option<&str>, out: &mut Compiled) -> Option<EntityFile> {
1438 use yog_data::Value;
1439 let mut value = match yog_data::text::parse_value(text) {
1440 Ok(v) => v,
1441 Err(e) => {
1442 cx.err(e.at.line as usize, e.message);
1443 return None;
1444 }
1445 };
1446 let Value::Map(map) = &mut value else {
1447 cx.err(1, "an entity is an object: { \"kind\": \"mob\", \"width\": 0.6, \"height\": 1.8, … }");
1448 return None;
1449 };
1450 let given = map.get("id").and_then(Value::as_str).map(str::to_owned);
1451 let (ns, p) = file_id(cx, 1, given.as_deref(), path, mod_ns)?;
1452 let id = format!("{ns}:{p}");
1453 map.insert("id", Value::from(id.as_str()));
1454 if let Some(name) = map.remove("name") {
1456 let key = format!("entity.{ns}.{}", p.replace('/', "."));
1457 match yog_data::from_value::<yog_data::Text>(&name) {
1458 Ok(read) => match read.value {
1459 yog_data::Text::Literal(s) => {
1460 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(key, s);
1461 }
1462 yog_data::Text::Lang(k) => out.lang_aliases.push((k, key)),
1463 yog_data::Text::NameOf(r) => {
1464 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
1465 out.lang_aliases.push((format!("{}.{rns}.{}", r.kind, rp.replace('/', ".")), key));
1466 }
1467 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
1468 Some((k, _)) => out.lang_aliases.push((k.clone(), key)),
1469 None => cx.err(1, format!("name `@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1470 },
1471 },
1472 Err(problems) => {
1473 for pr in problems {
1474 cx.err(1, format!("name: {}", pr.message));
1475 }
1476 }
1477 }
1478 }
1479 let def = match yog_data::from_value::<yog_registry::EntityDef>(&value) {
1480 Ok(read) => read.value,
1481 Err(problems) => {
1482 for pr in problems {
1483 cx.err(1, format!("{}: {}", pr.path, pr.message));
1484 }
1485 return None;
1486 }
1487 };
1488 let qualify = |s: &Option<String>| s.as_ref().map(|s| if s.contains(':') { s.clone() } else { format!("{ns}:{s}") });
1489 let mut def = def;
1490 def.model = qualify(&def.model);
1491 def.animations = qualify(&def.animations);
1492 Some(EntityFile {
1493 id,
1494 file: cx.file.clone(),
1495 model: def.model.clone(),
1496 animations: def.animations.clone(),
1497 data: yog_data::binary::encode(&yog_data::Schema::write(&def)),
1498 })
1499}
1500
1501struct BodyFile {
1503 model: bool,
1504 id: String,
1506 data: Vec<u8>,
1508}
1509
1510struct FontFile {
1512 id: String,
1514 data: Vec<u8>,
1516}
1517
1518impl FontFile {
1519 fn path(&self) -> String {
1520 format!("fonts/{}.yfont", self.id.replace([':', '/'], "__"))
1521 }
1522}
1523
1524fn compile_font(shown: &str, text: &str, root: &Path, path: &Path, ns: &str, errors: &mut Vec<String>) -> Option<FontFile> {
1526 let font = match yog_data::from_text::<yog_font::Font>(text) {
1527 Ok(read) => read.value,
1528 Err(problems) => {
1529 for problem in problems.0 {
1530 errors.push(format!("{shown}: {problem}"));
1531 }
1532 return None;
1533 }
1534 };
1535 if let Err(problems) = font.validate() {
1536 errors.extend(problems.into_iter().map(|problem| format!("{shown}: {problem}")));
1537 return None;
1538 }
1539 let rel = path.strip_prefix(root.join(CONTENT_DIR)).unwrap_or(path).with_extension("");
1540 let id = format!("{ns}:{}", rel.to_string_lossy().replace('\\', "/"));
1541 Some(FontFile { id, data: font.to_bytes() })
1542}
1543
1544impl BodyFile {
1545 fn path(&self) -> String {
1547 let dir = if self.model { "models" } else { "anims" };
1548 format!("body/{dir}/{}.ydata", self.id.replace([':', '/'], "__"))
1549 }
1550}
1551
1552fn compile_body(shown: &str, text: &str, kind: Kind, root: &Path, path: &Path, ns: &str, errors: &mut Vec<String>) -> Option<BodyFile> {
1555 let rel = path.strip_prefix(root.join(CONTENT_DIR)).unwrap_or(path).with_extension("");
1556 let id = format!("{ns}:{}", rel.to_string_lossy().replace('\\', "/"));
1557 let report = |errors: &mut Vec<String>, e: yog_data::Errors| {
1558 for l in e.0 {
1559 errors.push(format!("{shown}:{l}"));
1560 }
1561 };
1562 let value = if kind == Kind::Model {
1563 let file = match yog_data::from_text::<yog_body::format::ModelFile>(text) {
1564 Ok(r) => r.value,
1565 Err(e) => {
1566 report(errors, e);
1567 return None;
1568 }
1569 };
1570 if let Err(e) = yog_body::Model::new(&file, ns) {
1571 errors.push(format!("{shown}: {e}"));
1572 return None;
1573 }
1574 yog_data::Schema::write(&file)
1575 } else {
1576 let file = match yog_data::from_text::<yog_body::format::AnimFile>(text) {
1577 Ok(r) => r.value,
1578 Err(e) => {
1579 report(errors, e);
1580 return None;
1581 }
1582 };
1583 if let Err(e) = yog_body::anim::Clips::new(&file) {
1584 errors.push(format!("{shown}: {e}"));
1585 return None;
1586 }
1587 yog_data::Schema::write(&file)
1588 };
1589 Some(BodyFile { model: kind == Kind::Model, id, data: yog_data::binary::encode(&value) })
1590}
1591
1592fn dimension_path(id: &str) -> String {
1594 format!("dimensions/{}.ydata", id.replace(':', "__"))
1595}
1596
1597impl BookFile {
1598 fn path(&self) -> String {
1600 format!("books/{}.ydata", self.id.replace(':', "__"))
1601 }
1602}
1603
1604fn compile_book(
1613 cx: &mut Ctx,
1614 text: &str,
1615 mod_ns: Option<&str>,
1616 known: &refs::Known,
1617 layouts: &[yog_book::layout::RecipeLayout],
1618 recipe_kinds: &HashMap<String, String>,
1619 page_types: &[yog_book::page_type::PageType],
1620) -> Option<BookFile> {
1621 use refs::RefKind;
1622 use yog_data::schema::{Resolve, Text};
1623 use yog_data::{Path, Ref, Res};
1624
1625 struct Check<'a> {
1626 known: &'a refs::Known,
1627 texts: &'a HashMap<String, (String, String)>,
1628 entries: HashSet<String>,
1629 categories: HashSet<String>,
1630 text_refs: bool,
1631 }
1632
1633 impl Resolve for Check<'_> {
1634 fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
1635 let own = match r.kind.as_str() {
1636 "entry" => Some(&self.entries),
1637 "category" => Some(&self.categories),
1638 _ => None,
1639 };
1640 match (own, RefKind::from_word(&r.kind)) {
1641 (Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
1642 (Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
1643 (None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
1644 (None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
1645 }
1646 }
1647
1648 fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
1649 match t {
1650 Text::Literal(s) => Ok(s.clone()),
1651 Text::NameOf(r) => {
1653 if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
1654 return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
1655 }
1656 self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
1657 let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
1658 self.text_refs = true;
1659 Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
1660 }
1661 Text::Lang(key) => {
1663 self.text_refs = true;
1664 Ok(key.clone())
1665 }
1666 Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
1667 Some((key, _)) => {
1668 self.text_refs = true;
1669 Ok(key.clone())
1670 }
1671 None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1672 },
1673 }
1674 }
1675
1676 fn words(&mut self, r: &Res) -> Option<String> {
1677 self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
1678 }
1679 }
1680
1681 let doc = match yog_data::text::parse(text) {
1683 Ok(d) => d,
1684 Err(e) => {
1685 cx.err(e.at.line as usize, e.message);
1686 return None;
1687 }
1688 };
1689 let ids = |key: &str| -> HashSet<String> {
1690 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()
1691 };
1692 let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
1693 let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
1694 Ok(read) => read.value,
1695 Err(errors) => {
1696 for e in errors.0 {
1697 let line = e.at.map_or(1, |a| a.line as usize);
1698 if e.path.is_root() {
1699 cx.err(line, e.message);
1700 } else {
1701 cx.err(line, format!("{}: {}", e.path, e.message));
1702 }
1703 }
1704 return None;
1705 }
1706 };
1707 if !book.id.contains(':') {
1708 book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
1709 }
1710 let ns = mod_ns.unwrap_or("minecraft");
1712 let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
1713 .chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
1714 .filter(|(_, id)| !id.contains(':'))
1715 .map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
1716 .collect();
1717 if !unnamespaced.is_empty() {
1718 for e in unnamespaced {
1719 cx.err(1, e);
1720 }
1721 return None;
1722 }
1723 if !valid_id(&book.id) {
1724 cx.err(1, format!("book id `{}`: not a valid id", book.id));
1725 return None;
1726 }
1727 let mut used: Vec<yog_book::page_type::PageType> = Vec::new();
1730 let mut fields_ok = true;
1731 for (e, entry) in book.entries.iter_mut().enumerate() {
1732 for (p, page) in entry.pages.iter_mut().enumerate() {
1733 let yog_book::BookPage::Custom(c) = page else { continue };
1734 use yog_book::page_type::PropKind as K;
1735 use yog_data::with::With;
1736 let at = Path::root().key("entries").index(e).key("pages").index(p);
1737 let line = |path: &Path| doc.locate(text, path).line as usize;
1738 let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
1739 if c.kind.split(':').next() == mod_ns {
1740 cx.err(line(&at.key("type")), format!("{}.type: no page type {} — a .yui component with book_page=\"{}\"", at, c.kind, c.kind));
1741 fields_ok = false;
1742 } else {
1743 cx.warn(line(&at.key("type")), format!("{}.type: {} is another mod's page type — not checked here", at, c.kind));
1744 }
1745 continue;
1746 };
1747 for (k, _) in c.fields.iter() {
1748 if k != "anchor" && !t.properties.iter().any(|p| p.name == k) {
1749 let names: Vec<&str> = t.properties.iter().map(|p| p.name.as_str()).collect();
1750 cx.err(line(&at.key(k)), format!("{}: {} has no field `{k}` ({})", at.key(k), c.kind, names.join(", ")));
1751 fields_ok = false;
1752 }
1753 }
1754 for prop in &t.properties {
1755 let path = at.key(&prop.name);
1756 let Some(v) = c.fields.get(&prop.name).cloned() else {
1757 if prop.default.is_none() {
1758 cx.err(line(&at), format!("{at}: missing `{}` ({})", prop.name, c.kind));
1759 fields_ok = false;
1760 }
1761 continue;
1762 };
1763 let arena = yog_data::Bump::new();
1764 let r = v.borrow(&arena);
1765 let mut reader = yog_data::Reader::with_resolver(&mut check);
1766 let read = match prop.kind {
1767 K::Text => yog_data::with::TextOf::read(&r, &mut reader).map(yog_data::Value::Str),
1768 K::BookText => yog_book::refs::BookText::read(&r, &mut reader).map(yog_data::Value::Str),
1769 K::Item => yog_data::with::RefTo::<(yog_data::kinds::Item, yog_data::kinds::Block)>::read(&r, &mut reader).map(yog_data::Value::Str),
1770 K::Image => yog_book::refs::Icon::read(&r, &mut reader).map(yog_data::Value::Str),
1771 K::Number => <f64 as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Float),
1772 K::Bool => <bool as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Bool),
1773 K::Data => Some(v.clone()),
1774 };
1775 let problems = std::mem::take(&mut reader.problems);
1776 drop(reader);
1777 for pr in problems {
1778 cx.err(line(&path), format!("{path}: {}", pr.message));
1779 fields_ok = false;
1780 }
1781 if let Some(out) = read {
1782 c.fields.insert(prop.name.as_str(), out);
1783 }
1784 }
1785 if !used.contains(t) {
1786 used.push(t.clone());
1787 }
1788 }
1789 }
1790 if !fields_ok {
1791 return None;
1792 }
1793 book.page_types = used;
1794 book.i18n |= check.text_refs;
1795 let mut ok = true;
1797 for (e, entry) in book.entries.iter().enumerate() {
1798 for (p, page) in entry.pages.iter().enumerate() {
1799 if let yog_book::BookPage::Recipe { recipe, .. } = page
1800 && let Some(kind) = recipe_kinds.get(recipe)
1801 && yog_book::layout::find(layouts, kind).is_none()
1802 {
1803 let path = yog_data::Path::root().key("entries").index(e).key("pages").index(p).key("recipe");
1804 let short = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
1805 cx.err(
1806 doc.locate(text, &path).line as usize,
1807 format!("{path}: no layout for recipe kind {kind} — lay it out in a .yui: <Component recipe_layout=\"{short}\" width=\"…\" height=\"…\"> (see yog_book::layout)"),
1808 );
1809 ok = false;
1810 }
1811 }
1812 }
1813 if !ok {
1814 return None;
1815 }
1816 book.layouts = layouts.to_vec();
1817 Some(BookFile { id: book.id.clone(), data: yog_data::binary::encode(&yog_data::Schema::write(&book)) })
1818}
1819
1820fn compile_layout(
1824 cx: &mut Ctx,
1825 text: &str,
1826 mod_ns: Option<&str>,
1827 known: &refs::Known,
1828 ores: &mut Vec<OreOut>,
1829 dimensions: &mut Vec<(String, Vec<u8>)>,
1830 tables: &mut Vec<table::Table>,
1831 loot: &mut Vec<(String, Vec<u8>)>,
1832 definitions: &mut Vec<(&'static str, String, Vec<u8>)>,
1833 advancements: &mut Vec<String>,
1834 out: &mut Compiled,
1835) -> Option<yog_formats::def::RecipeKindDef> {
1836 use yog_formats::def::Def;
1837 let doc = match yog_data::text::parse(text) {
1838 Ok(d) => d,
1839 Err(e) => {
1840 cx.err(e.at.line as usize, e.message);
1841 return None;
1842 }
1843 };
1844 let read = match yog_data::schema::from_doc::<Def>(text, &doc) {
1845 Ok(r) => r,
1846 Err(errors) => {
1847 for e in errors.0 {
1848 let l = e.at.map_or(1, |a| a.line as usize);
1849 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1850 }
1851 return None;
1852 }
1853 };
1854 let mut ok = true;
1855 for f in &read.refs {
1856 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1858 continue;
1859 }
1860 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1861 && let Err(e) = known.check(kind, &f.value.id)
1862 {
1863 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1864 ok = false;
1865 }
1866 }
1867 for f in &read.tags {
1868 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1869 continue;
1870 }
1871 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
1872 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1873 ok = false;
1874 }
1875 }
1876 let ns = mod_ns.unwrap_or("minecraft");
1877 let kind = match read.value {
1878 Def::RecipeKind(mut k) => {
1879 if !k.id.contains(':') {
1880 k.id = format!("{ns}:{}", k.id);
1881 }
1882 if k.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1883 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));
1884 ok = false;
1885 }
1886 k
1887 }
1888 Def::Ore(o) => {
1889 if ok {
1890 ores.push(ore(o, ns));
1891 }
1892 return None;
1893 }
1894 Def::Dimension(d) => {
1895 if ok {
1896 dimensions.push(dimension(d, ns));
1897 }
1898 return None;
1899 }
1900 Def::Tag(t) => {
1901 let line = doc.locate(text, &yog_data::Path::root().key("values")).line as usize;
1902 match tag_entries(&t, ns) {
1903 Ok((path, entries)) => out.tags.entry(path).or_default().extend(entries),
1904 Err(problems) => problems.into_iter().for_each(|p| cx.err(line, p)),
1905 }
1906 return None;
1907 }
1908 Def::DamageType(d) => {
1909 match damage_type(&d, ns) {
1910 Ok((file, tags)) => {
1911 out.outputs.push(file);
1912 for (path, id) in tags {
1913 out.tags.entry(path).or_default().insert(id, true);
1914 }
1915 }
1916 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1917 }
1918 return None;
1919 }
1920 Def::Tree(t) => {
1921 match tree(&t, ns) {
1922 Ok(file) => {
1923 if ok {
1924 out.outputs.push(file);
1925 }
1926 }
1927 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1928 }
1929 return None;
1930 }
1931 Def::LootModifier(mut m) => {
1932 m.id = full_id(&m.id, ns);
1933 if m.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1934 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own, `{ns}:<name>`", m.id));
1935 ok = false;
1936 }
1937 if ok {
1938 use yog_data::Schema;
1939 loot.push((m.id.clone(), yog_data::binary::encode(&m.write())));
1940 }
1941 return None;
1942 }
1943 Def::Advancement(a) => {
1946 let at = |key: &str| doc.locate(text, &yog_data::Path::root().key(key)).line as usize;
1947 match advancement(cx, &mut out.lang, &a, ns) {
1948 Ok(files) if ok => {
1949 out.outputs.extend(files);
1950 advancements.push(full_id(&a.id, ns));
1951 }
1952 Ok(_) => {}
1953 Err((key, e)) => cx.err(at(key), format!("{key}: {e}")),
1954 }
1955 return None;
1956 }
1957 Def::Attribute(mut a) => {
1959 a.id = full_id(&a.id, ns);
1960 content_def(cx, &doc, text, ns, "attribute", a.id.clone(), &a, ok, definitions);
1961 return None;
1962 }
1963 Def::Effect(mut e) => {
1964 e.id = full_id(&e.id, ns);
1965 content_def(cx, &doc, text, ns, "effect", e.id.clone(), &e, ok, definitions);
1966 return None;
1967 }
1968 Def::LootTable(t) => {
1969 let id = full_id(&t.id, ns);
1970 let Some(path) = id.strip_prefix(&format!("{ns}:")).filter(|p| valid_path(p)) else {
1971 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
1972 return None;
1973 };
1974 if ok {
1975 use yog_data::Schema;
1976 out.outputs.push((format!("data/{ns}/loot_table/{path}.json"), pretty(&json!({ "type": "minecraft:generic", "pools": [] }))));
1977 let fill = yog_registry::LootModifier { id: id.clone(), tables: vec![id.clone()], add: t.add, scale: Vec::new() };
1978 loot.push((id, yog_data::binary::encode(&fill.write())));
1979 }
1980 return None;
1981 }
1982 Def::Table(t) => {
1983 match table::check(&cx.file, t, cx.refs, known) {
1984 Ok(t) => tables.push(t),
1985 Err(errors) => {
1986 for e in errors {
1987 let path = e.split_once(':').map_or("", |(p, _)| p);
1989 let at = path.split('.').fold(yog_data::Path::root(), |at, k| at.key(k));
1990 cx.err(doc.locate(text, &at).line as usize, e);
1991 }
1992 }
1993 }
1994 return None;
1995 }
1996 _ => return None,
1997 };
1998 ok.then_some(kind)
1999}
2000
2001fn item_props(p: &ItemProps) -> String {
2004 let mut b = String::new();
2005 if let Some(s) = p.stack_size {
2006 let _ = write!(b, "\n .max_stack({s})");
2007 }
2008 if p.durability > 0 {
2009 let _ = write!(b, "\n .max_damage({})", p.durability);
2010 }
2011 if p.fire_resistant {
2012 b.push_str("\n .fire_resistant()");
2013 }
2014 if p.fuel_ticks > 0 {
2015 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
2016 }
2017 if let Some((kind, material, d, s)) = &p.tool {
2018 let _ = write!(b, "\n .tool({kind:?}, {material:?}, {d:?}, {s:?})");
2019 }
2020 if let Some(slot) = &p.wearable {
2021 let _ = write!(b, "\n .wearable({slot:?})");
2022 }
2023 if let Some((entity, size)) = &p.hangs {
2024 let _ = write!(b, "\n .hangs({entity:?}, {size:?})");
2025 }
2026 if let Some((slot, material)) = &p.armor {
2027 let _ = write!(b, "\n .armor({slot:?}, {material:?})");
2028 }
2029 if let Some((n, s, always)) = p.food {
2030 let _ = write!(
2031 b,
2032 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
2033 float(s),
2034 if always { ".can_always_eat()" } else { "" }
2035 );
2036 }
2037 b
2038}
2039
2040fn text_key(cx: &mut Ctx, lang: &mut Lang, t: &yog_data::Text, ns: &str, key: String) -> Result<String, String> {
2043 Ok(match t {
2044 yog_data::Text::Literal(words) => {
2045 lang.entry((ns.to_string(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(key.clone(), words.clone());
2046 key
2047 }
2048 yog_data::Text::Lang(k) => k.clone(),
2049 yog_data::Text::NameOf(r) => {
2050 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
2051 format!("{}.{rns}.{}", r.kind, rp.replace('/', "."))
2052 }
2053 yog_data::Text::Res(r) => cx
2054 .refs
2055 .texts
2056 .get(&format!("{}.{}", r.set, r.key))
2057 .map(|(k, _)| k.clone())
2058 .ok_or_else(|| format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key))?,
2059 })
2060}
2061
2062type Lang = BTreeMap<(String, String), BTreeMap<String, String>>;
2064
2065fn old_advancement_format(version: &str) -> bool {
2068 let mut p = version.split('.').map(|n| n.parse::<u32>().unwrap_or(0));
2069 let (major, minor, patch) = (p.next().unwrap_or(0), p.next().unwrap_or(0), p.next().unwrap_or(0));
2070 (major, minor, patch) < (1, 20, 5)
2071}
2072
2073fn advancement(cx: &mut Ctx, lang: &mut Lang, a: &yog_formats::def::AdvancementDef, ns: &str) -> Result<Vec<Output>, (&'static str, String)> {
2076 use serde_json::json;
2077 let id = full_id(&a.id, ns);
2078 let path = id.strip_prefix(&format!("{ns}:")).filter(|p| valid_path(p)).ok_or(("id", format!("`{id}`: the mod's own, `{ns}:<name>`")))?.to_string();
2079 if !matches!(a.frame.as_str(), "task" | "goal" | "challenge") {
2080 return Err(("frame", format!("`{}`: task, goal or challenge", a.frame)));
2081 }
2082 let base_key = format!("advancement.{ns}.{}", path.replace('/', "."));
2083 let title = text_key(cx, lang, &a.title, ns, base_key.clone()).map_err(|e| ("title", e))?;
2084 let description = match &a.description {
2085 Some(d) => json!({ "translate": text_key(cx, lang, d, ns, format!("{base_key}.desc")).map_err(|e| ("description", e))? }),
2086 None => json!({ "text": "" }),
2087 };
2088 let make = |old: bool| -> Result<Value, (&'static str, String)> {
2089 let item_predicate = |item: &str| if old { json!({ "items": [item] }) } else { json!({ "items": item }) };
2090 let mut criteria = Map::new();
2091 let mut requirements = Vec::new();
2092 for (i, c) in a.criteria.iter().enumerate() {
2093 let kinds = c.granted as u8 + c.has_item.is_some() as u8 + c.used_item.is_some() as u8;
2094 if kinds != 1 {
2095 return Err(("criteria", format!("criteria[{i}]: one of granted, has_item, used_item")));
2096 }
2097 let name = format!("c{i}");
2098 let criterion = if c.granted {
2099 json!({ "trigger": "minecraft:impossible" })
2100 } else if let Some(item) = &c.has_item {
2101 let predicate = match item {
2102 yog_data::OneOrTag::One(i) => item_predicate(&full_id(&i.id, "minecraft")),
2103 yog_data::OneOrTag::Tag(t) if old => json!({ "tag": full_id(&t.id, "minecraft") }),
2104 yog_data::OneOrTag::Tag(t) => json!({ "items": format!("#{}", full_id(&t.id, "minecraft")) }),
2105 };
2106 json!({ "trigger": "minecraft:inventory_changed", "conditions": { "items": [predicate] } })
2107 } else {
2108 let item = c.used_item.as_ref().map(|i| full_id(&i.id, "minecraft")).unwrap_or_default();
2109 json!({ "trigger": "minecraft:consume_item", "conditions": { "item": item_predicate(&item) } })
2110 };
2111 criteria.insert(name.clone(), criterion);
2112 requirements.push(json!([name]));
2113 }
2114 let mut display = json!({
2115 "icon": if old { json!({ "item": a.icon }) } else { json!({ "id": a.icon }) },
2116 "title": { "translate": title },
2117 "description": description,
2118 "frame": a.frame,
2119 "hidden": a.hidden,
2120 "show_toast": true,
2121 "announce_to_chat": true,
2122 });
2123 if let Some(bg) = &a.background {
2124 display["background"] = Value::String(bg.clone());
2125 }
2126 let mut v = json!({ "display": display, "criteria": criteria, "requirements": requirements });
2127 if let Some(parent) = &a.parent {
2128 v["parent"] = Value::String(full_id(parent, ns));
2129 }
2130 Ok(v)
2131 };
2132 let file = format!("data/{ns}/advancement/{path}.json");
2133 let mut out = vec![(file.clone(), pretty(&make(false)?))];
2134 for version in refs::versions().iter().filter(|v| old_advancement_format(v)) {
2135 out.push((format!("when/any/{version}/{file}"), pretty(&make(true)?)));
2136 }
2137 Ok(out)
2138}
2139
2140#[allow(clippy::too_many_arguments)]
2143fn content_def<T: yog_data::Schema>(
2144 cx: &mut Ctx,
2145 doc: &yog_data::text::Doc,
2146 text: &str,
2147 ns: &str,
2148 kind: &'static str,
2149 id: String,
2150 def: &T,
2151 ok: bool,
2152 definitions: &mut Vec<(&'static str, String, Vec<u8>)>,
2153) {
2154 if id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
2155 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
2156 return;
2157 }
2158 if ok {
2159 definitions.push((kind, id, yog_data::binary::encode(&def.write())));
2160 }
2161}
2162
2163fn definition_path(kind: &str, id: &str) -> String {
2165 format!("{kind}/{}.ydata", id.replace([':', '/'], "__"))
2166}
2167
2168fn loot_path(id: &str) -> String {
2170 format!("loot/{}.ydata", id.replace([':', '/'], "__"))
2171}
2172
2173fn full_id(id: &str, ns: &str) -> String {
2175 if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") }
2176}
2177
2178const TAG_REF: &str = "tag:";
2180
2181fn tag_entries(t: &yog_formats::def::TagDef, ns: &str) -> Result<(String, BTreeMap<String, bool>), Vec<String>> {
2184 use yog_data::AnyRefOrTag;
2185 let id = full_id(&t.id, ns);
2186 let (tns, tpath) = id.split_once(':').unwrap_or(("minecraft", &id));
2187 let mut problems = Vec::new();
2188 let mut entries = BTreeMap::new();
2189 for (list, required) in [(&t.values, true), (&t.optional, false)] {
2190 for v in list {
2191 if v.kind() != t.of {
2192 problems.push(format!("`{v}` in a tag of {}", t.of));
2193 continue;
2194 }
2195 let (entry, required) = match v {
2196 AnyRefOrTag::One(r) => (full_id(&r.id, "minecraft"), required),
2197 AnyRefOrTag::Tag(r) => {
2199 let tag = full_id(&r.id, "minecraft");
2200 let everywhere = !matches!(refs::tag_hosts(refs::tag_registry(&t.of), &tag), Some(Some(_)));
2201 (format!("#{tag}"), required && everywhere)
2202 }
2203 };
2204 entries.insert(entry, required);
2205 }
2206 }
2207 if problems.is_empty() {
2208 Ok((format!("data/{tns}/tags/{}/{tpath}.json", refs::tag_registry(&t.of)), entries))
2209 } else {
2210 Err(problems)
2211 }
2212}
2213
2214fn tree(t: &yog_formats::def::TreeDef, ns: &str) -> Result<Output, String> {
2216 use serde_json::json;
2217 let id = full_id(&t.id, ns);
2218 let (tns, tpath) = id.split_once(':').unwrap_or((ns, &id));
2219 if tns != ns || !valid_path(tpath) {
2220 return Err(format!("id: `{}`: the mod's own tree, `{ns}:<name>`", t.id));
2221 }
2222 let provider = |blocks: &[yog_formats::def::WeightedBlock]| match blocks {
2224 [one] => json!({ "type": "minecraft:simple_state_provider", "state": { "Name": one.block.id } }),
2225 many => json!({
2226 "type": "minecraft:weighted_state_provider",
2227 "entries": many.iter().map(|b| json!({ "data": { "Name": b.block.id }, "weight": b.weight })).collect::<Vec<_>>()
2228 }),
2229 };
2230 let placer = |shape: &str, what: &str| if shape.contains(':') { shape.to_string() } else { format!("minecraft:{shape}_{what}_placer") };
2232 if t.trunk.blocks.is_empty() || t.leaves.blocks.is_empty() {
2233 return Err("a tree needs trunk and leaves blocks".into());
2234 }
2235 let mut foliage = json!({ "type": placer(&t.leaves.shape, "foliage"), "radius": t.leaves.radius, "offset": t.leaves.offset });
2236 if let Some(h) = t.leaves.height {
2237 foliage["height"] = h.into();
2238 }
2239 let mut size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 1, "lower_size": 0, "upper_size": 1 });
2240 if let Some(h) = t.min_clipped_height {
2241 size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 0, "lower_size": 0, "upper_size": 0, "min_clipped_height": h });
2242 }
2243 let dirt = t.dirt.as_ref().map_or("minecraft:dirt", |b| b.id.as_str());
2244 let v = json!({
2245 "type": "minecraft:tree",
2246 "config": {
2247 "trunk_provider": provider(&t.trunk.blocks),
2248 "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 },
2249 "foliage_provider": provider(&t.leaves.blocks),
2250 "foliage_placer": foliage,
2251 "dirt_provider": { "type": "minecraft:simple_state_provider", "state": { "Name": dirt } },
2252 "minimum_size": size,
2253 "decorators": [],
2254 "ignore_vines": t.ignore_vines,
2255 "force_dirt": t.force_dirt
2256 }
2257 });
2258 Ok((format!("data/{tns}/worldgen/configured_feature/{tpath}.json"), pretty(&v)))
2259}
2260
2261fn damage_type(d: &yog_formats::def::DamageTypeDef, ns: &str) -> Result<(Output, Vec<(String, String)>), String> {
2264 let id = full_id(&d.id, ns);
2265 let (dns, dpath) = id.split_once(':').unwrap_or((ns, &id));
2266 if dns != ns || !valid_path(dpath) {
2267 return Err(format!("id: `{}`: the mod's own damage type, `{ns}:<name>`", d.id));
2268 }
2269 let mut v = serde_json::json!({
2270 "message_id": d.message_id.clone().unwrap_or_else(|| format!("{dns}.{dpath}")),
2271 "scaling": d.scaling,
2272 "exhaustion": d.exhaustion,
2273 });
2274 if let Some(e) = &d.effects {
2275 v["effects"] = Value::String(e.clone());
2276 }
2277 if let Some(m) = &d.death_message_type {
2278 v["death_message_type"] = Value::String(m.clone());
2279 }
2280 let tags = d
2281 .tags
2282 .iter()
2283 .map(|t| {
2284 let t = full_id(t, "minecraft");
2285 let (tns, tpath) = t.split_once(':').unwrap_or(("minecraft", &t));
2286 (format!("data/{tns}/tags/damage_type/{tpath}.json"), id.clone())
2287 })
2288 .collect();
2289 Ok(((format!("data/{dns}/damage_type/{dpath}.json"), pretty(&v)), tags))
2290}
2291
2292fn recipe_layouts(
2296 root: &Path,
2297 ns: &str,
2298 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
2299 known: &refs::Known,
2300 errors: &mut Vec<String>,
2301) -> Vec<yog_book::layout::RecipeLayout> {
2302 use yog_book::layout::LayoutPart;
2303 let mut laid_out: HashMap<String, String> = HashMap::new();
2304 let mut out = Vec::new();
2305 for (shown, l) in crate::book_page::find_layouts(root, ns, errors) {
2306 let mut ok = true;
2307 for k in &l.kinds {
2308 let game = yog_formats::recipe::TYPES.contains(&k.as_str());
2309 if !game && !kinds.contains_key(k) {
2310 errors.push(format!(
2311 "{shown}: recipe_layout: `{k}`: one of {}, or a kind the mod declares (a .ydef of kind recipe_kind)",
2312 yog_formats::recipe::TYPES.join(", ")
2313 ));
2314 ok = false;
2315 }
2316 if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
2317 errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
2318 ok = false;
2319 }
2320 }
2321 for part in &l.parts {
2322 match part {
2323 LayoutPart::Slots { slot, .. } => {
2324 for k in l.kinds.iter().filter_map(|k| kinds.get(k)) {
2325 if !k.slots.0.iter().any(|(s, _)| s == slot) {
2326 let all: Vec<&str> = k.slots.0.iter().map(|(s, _)| s.as_str()).collect();
2327 errors.push(format!("{shown}: <RecipeSlot slot=\"{slot}\">: {} has no slot `{slot}` — {}", k.id, all.join(", ")));
2328 ok = false;
2329 }
2330 }
2331 }
2332 LayoutPart::Item { item, .. } => {
2333 if let Err(e) = known.check(refs::RefKind::Item, item) {
2334 errors.push(format!("{shown}: <ItemSlot item=\"{item}\">: {e}"));
2335 ok = false;
2336 }
2337 }
2338 _ => {}
2339 }
2340 }
2341 if ok {
2342 out.push(l);
2343 }
2344 }
2345 out
2346}
2347
2348fn dimension(d: yog_formats::def::DimensionDef, ns: &str) -> (String, Vec<u8>) {
2352 use yog_data::{Map, Schema, Value as V};
2353 let id = if d.id.contains(':') { d.id.clone() } else { format!("{ns}:{}", d.id) };
2354 let mut m = Map::new();
2355 m.insert("id", V::from(id.as_str()));
2356 m.insert("dimension_type", d.dimension_type.write());
2357 m.insert("biome", V::from(d.biome.map_or("minecraft:plains".to_string(), |b| b.id).as_str()));
2358 m.insert("structures", V::Bool(d.structures));
2359 m.insert("features", V::Bool(d.features));
2360 m.insert("mob_spawning", V::Bool(d.mob_spawning));
2361 if let Some(t) = &d.terrain {
2362 m.insert("generator_type", V::from(id.as_str()));
2363 m.insert("terrain", t.write());
2364 } else if let Some(g) = &d.generator {
2365 m.insert("generator_type", V::from(if g.contains(':') { g.clone() } else { format!("{ns}:{g}") }.as_str()));
2366 }
2367 (id, yog_data::binary::encode(&V::Map(m)))
2368}
2369
2370type OreOut = (Vec<Output>, (String, String, String));
2372
2373fn ore(o: yog_formats::def::OreDef, ns: &str) -> OreOut {
2377 use serde_json::json;
2378 let p = o.id.split_once(':').map_or(o.id.as_str(), |(_, p)| p).to_string();
2379 let id = format!("{ns}:{p}");
2380 let targets: Vec<Value> = o
2381 .targets
2382 .iter()
2383 .map(|t| {
2384 let target = match t.replaces.strip_prefix('#') {
2385 Some(tag) => json!({ "predicate_type": "minecraft:tag_match", "tag": tag }),
2386 None => json!({ "predicate_type": "minecraft:block_match", "block": t.replaces }),
2387 };
2388 json!({ "target": target, "state": { "Name": t.block.id } })
2389 })
2390 .collect();
2391 let configured = json!({
2392 "type": "minecraft:ore",
2393 "config": { "size": o.size, "discard_chance_on_air_exposure": o.air_exposure, "targets": targets }
2394 });
2395 let shape = match o.height.shape {
2396 yog_formats::def::HeightShape::Uniform => "minecraft:uniform",
2397 yog_formats::def::HeightShape::Triangle => "minecraft:trapezoid",
2398 };
2399 let placed = json!({
2400 "feature": id,
2401 "placement": [
2402 { "type": "minecraft:count", "count": o.per_chunk },
2403 { "type": "minecraft:in_square" },
2404 { "type": "minecraft:height_range", "height": { "type": shape, "min_inclusive": { "absolute": o.height.min }, "max_inclusive": { "absolute": o.height.max } } },
2405 { "type": "minecraft:biome" }
2406 ]
2407 });
2408 let biomes = o.biomes.map(|b| b.id).unwrap_or_else(|| "minecraft:is_overworld".into());
2409 let modifier = |kind: &str| json!({ "type": kind, "biomes": format!("#{biomes}"), "features": id, "step": "underground_ores" });
2410 let mut files: Vec<Output> = Vec::new();
2411 let mut put = |path: String, v: Value| files.push((path, pretty(&v)));
2412 put(format!("data/{ns}/worldgen/configured_feature/{p}.json"), configured);
2413 put(format!("data/{ns}/worldgen/placed_feature/{p}.json"), placed);
2414 for (dir, kind) in [("when/forge/any", "forge:add_features"), ("when/neoforge/1.20", "forge:add_features")] {
2415 put(format!("{dir}/data/{ns}/forge/biome_modifier/{p}.json"), modifier(kind));
2416 }
2417 put(format!("when/neoforge/1.21/data/{ns}/neoforge/biome_modifier/{p}.json"), modifier("neoforge:add_features"));
2418 (files, (id, biomes, "underground_ores".into()))
2419}
2420
2421#[derive(Debug, Clone)]
2423struct Recipe {
2424 id: String,
2425 ty: String,
2427 kind: RecipeKind,
2428 result: String,
2429 count: u32,
2430 group: Option<String>,
2431 category: Option<String>,
2432 requires: Vec<String>,
2434}
2435
2436impl Recipe {
2437 fn registration(&self) -> String {
2439 let list = |items: &[String]| {
2440 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
2441 format!("vec![{}]", quoted.join(", "))
2442 };
2443 let kind = match &self.kind {
2444 RecipeKind::Shaped { pattern, key } => {
2445 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
2446 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
2447 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
2448 }
2449 RecipeKind::Shapeless(ingredients) => {
2450 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
2451 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
2452 }
2453 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
2454 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
2455 list(input),
2456 float(*experience)
2457 ),
2458 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
2459 RecipeKind::Custom { kind, slots, values } => {
2460 let mut o = Map::new();
2462 o.insert("type".into(), kind.clone().into());
2463 o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
2464 if !values.is_empty() {
2465 o.insert("values".into(), Value::Object(values.clone()));
2466 }
2467 o.insert("result".into(), self.result.clone().into());
2468 if self.count != 1 {
2469 o.insert("count".into(), self.count.into());
2470 }
2471 if let Some(g) = &self.group {
2472 o.insert("group".into(), g.clone().into());
2473 }
2474 return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
2475 }
2476 RecipeKind::Smithing { template, base, addition } => format!(
2477 "Smithing {{ template: {}, base: {}, addition: {} }}",
2478 list(template),
2479 list(base),
2480 list(addition)
2481 ),
2482 };
2483 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
2484 format!(
2485 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
2486 self.id,
2487 self.result,
2488 self.count,
2489 opt(&self.group),
2490 opt(&self.category)
2491 )
2492 }
2493}
2494
2495fn compile_recipe(
2499 cx: &mut Ctx,
2500 text: &str,
2501 path: &Path,
2502 mod_ns: Option<&str>,
2503 known: &refs::Known,
2504 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
2505 brews: &mut Vec<(String, yog_registry::Brew)>,
2506) -> Option<Recipe> {
2507 use yog_formats::recipe::{Body, RecipeFile};
2508 use yog_data::Path as At;
2509
2510 let doc = match yog_data::text::parse(text) {
2511 Ok(d) => d,
2512 Err(e) => {
2513 cx.err(e.at.line as usize, e.message);
2514 return None;
2515 }
2516 };
2517 let line = |p: &At| doc.locate(text, p).line as usize;
2518 let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
2519 Ok(r) => r,
2520 Err(errors) => {
2521 for e in errors.0 {
2522 let l = e.at.map_or(1, |a| a.line as usize);
2523 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
2524 }
2525 return None;
2526 }
2527 };
2528 let mut ok = true;
2529 for f in &read.refs {
2530 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
2531 && let Err(e) = known.check(kind, &f.value.id)
2532 {
2533 cx.err(line(&f.path), format!("{}: {e}", f.path));
2534 ok = false;
2535 }
2536 }
2537 for f in &read.tags {
2538 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
2539 cx.err(line(&f.path), format!("{}: {e}", f.path));
2540 ok = false;
2541 }
2542 }
2543 if ok {
2545 for (missing, why) in recipe_gaps(&read.refs, &read.tags) {
2546 cx.warn(line(&At::root()), format!("left out on {}: {why}", refs::describe_hosts(&missing)));
2547 }
2548 }
2549 let RecipeFile { common: c, body } = read.value;
2550 let ty = match &body {
2551 Body::Shaped(_) => "shaped".to_string(),
2552 Body::Shapeless(_) => "shapeless".to_string(),
2553 Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
2554 Body::Stonecutting(_) => "stonecutting".to_string(),
2555 Body::Smithing(_) => "smithing".to_string(),
2556 Body::Brewing(_) => "brewing".to_string(),
2557 };
2558 let root = At::root();
2559 if let Some(f) = c.format
2560 && f != 1
2561 {
2562 cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
2563 ok = false;
2564 }
2565 let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
2567 let id_line = line(&root.key("id"));
2568 let (ns, p) = match raw.split_once(':') {
2569 Some((ns, p)) => (ns.to_string(), p.to_string()),
2570 None => match mod_ns {
2571 Some(ns) => (ns.to_string(), raw.clone()),
2572 None => {
2573 cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
2574 return None;
2575 }
2576 },
2577 };
2578 if !valid_namespace(&ns) || !valid_path(&p) {
2579 cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
2580 return None;
2581 }
2582 if !(1..=64).contains(&c.count) {
2583 cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
2584 ok = false;
2585 }
2586 let kind = match body {
2587 Body::Shaped(s) => {
2588 let width = s.pattern.first().map_or(0, |r| r.chars().count());
2589 if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
2590 cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
2591 return None;
2592 }
2593 let mut key = Vec::new();
2594 for (k, ing) in &s.key.0 {
2595 let at = line(&root.key("key").key(k));
2596 let mut chars = k.chars();
2597 match (chars.next(), chars.next()) {
2598 (Some(ch), None) if ch != ' ' => {
2599 if !s.pattern.iter().any(|r| r.contains(ch)) {
2600 cx.warn(at, format!("key {k:?} isn't used by the pattern"));
2601 }
2602 key.push((ch, ing.ids()));
2603 }
2604 _ => {
2605 cx.err(at, format!("key {k:?}: one character other than space"));
2606 ok = false;
2607 }
2608 }
2609 }
2610 for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
2611 if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
2612 cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
2613 return None;
2614 }
2615 }
2616 RecipeKind::Shaped { pattern: s.pattern, key }
2617 }
2618 Body::Shapeless(s) => {
2619 if s.ingredients.is_empty() || s.ingredients.len() > 9 {
2620 cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
2621 return None;
2622 }
2623 RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
2624 }
2625 Body::Cooking(ty, k) => {
2626 let (method, default_time) = match ty.as_str() {
2627 "smelting" => ("Smelting", 200),
2628 "blasting" => ("Blasting", 100),
2629 "smoking" => ("Smoking", 100),
2630 _ => ("Campfire", 600),
2631 };
2632 if !(0.0..=1000.0).contains(&k.experience) {
2633 cx.err(line(&root.key("experience")), "experience: 0–1000");
2634 ok = false;
2635 }
2636 let cook_time = k.cook_time.unwrap_or(default_time);
2637 if !(1..=32767).contains(&cook_time) {
2638 cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
2639 ok = false;
2640 }
2641 RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
2642 }
2643 Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
2644 Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
2645 Body::Brewing(k) => {
2646 if c.count != 1 {
2647 cx.err(line(&root.key("count")), "brewing recipes make one potion");
2648 ok = false;
2649 }
2650 if c.category.is_some() {
2651 cx.err(line(&root.key("category")), "brewing recipes don't have a recipe-book category");
2652 ok = false;
2653 }
2654 if ok {
2655 brews.push((c.result.id.clone(), yog_registry::Brew { from: k.from, with: k.ingredient }));
2656 }
2657 return None;
2658 }
2659 Body::Custom(kind, k) => {
2660 let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
2662 if !own {
2663 cx.err(
2664 line(&root.key("type")),
2665 format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
2666 );
2667 return None;
2668 }
2669 if c.category.is_some() {
2670 cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
2671 ok = false;
2672 }
2673 let Some(def) = kinds.get(&kind) else {
2675 let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
2676 cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
2677 return None;
2678 };
2679 for (name, ings) in &k.slots.0 {
2680 let at = root.key("slots").key(name);
2681 match def.slots.0.iter().find(|(n, _)| n == name) {
2682 None => {
2683 let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
2684 cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
2685 ok = false;
2686 }
2687 Some((_, spec)) if ings.len() > spec.max as usize => {
2688 cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
2689 ok = false;
2690 }
2691 _ => {}
2692 }
2693 }
2694 for (name, spec) in &def.slots.0 {
2695 if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
2696 cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
2697 ok = false;
2698 }
2699 }
2700 for (name, v) in &k.values.0 {
2701 let at = root.key("values").key(name);
2702 let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
2703 None => {
2704 let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
2705 cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
2706 ok = false;
2707 continue;
2708 }
2709 Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
2710 Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
2711 Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
2712 };
2713 if !fits {
2714 cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
2715 ok = false;
2716 }
2717 }
2718 let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
2719 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();
2720 RecipeKind::Custom { kind, slots, values }
2721 }
2722 };
2723 ok.then(|| Recipe {
2724 id: format!("{ns}:{p}"),
2725 ty,
2726 kind,
2727 result: c.result.id.clone(),
2728 count: c.count,
2729 group: c.group,
2730 category: c.category,
2731 requires: c.requires,
2732 })
2733}
2734
2735fn recipe_gaps(items: &[yog_data::schema::Found<yog_data::Ref>], tags: &[yog_data::schema::Found<yog_data::Ref>]) -> Vec<(BTreeSet<String>, String)> {
2739 use refs::RefKind;
2740 let mut lists: BTreeMap<String, Vec<(String, Option<BTreeSet<String>>)>> = BTreeMap::new();
2742 let parent = |p: &yog_data::Path| {
2743 let mut p = p.clone();
2744 if matches!(p.segments().last(), Some(yog_data::Seg::Index(_))) {
2745 p.pop();
2746 }
2747 p.to_string()
2748 };
2749 for f in items {
2750 let Some(kind) = RefKind::from_word(&f.value.kind) else { continue };
2751 let potion_alias = kind == RefKind::Item && f.value.id.starts_with("yog:potion/");
2752 if !potion_alias && !f.value.id.starts_with("minecraft:") {
2753 continue;
2754 }
2755 let on: BTreeSet<String> = refs::hosts()
2756 .iter()
2757 .filter(|h| {
2758 h.split_once('/').is_some_and(|(_, v)| {
2759 if potion_alias {
2760 refs::is_vanilla_potion_in(&f.value.id, v)
2761 } else {
2762 refs::equivalent(kind, &f.value.id, v).is_some()
2763 }
2764 })
2765 })
2766 .cloned()
2767 .collect();
2768 let on = (on.len() < refs::hosts().len()).then_some(on);
2769 lists.entry(parent(&f.path)).or_default().push((f.value.id.clone(), on));
2770 }
2771 for f in tags {
2772 let Some(on) = refs::tag_hosts(refs::tag_registry(&f.value.kind), &f.value.id) else { continue };
2773 lists.entry(parent(&f.path)).or_default().push((format!("#{}", f.value.id), on));
2774 }
2775 let mut out = Vec::new();
2776 for alts in lists.values() {
2777 if alts.iter().any(|(_, on)| on.is_none()) {
2778 continue;
2779 }
2780 let on: BTreeSet<String> = alts.iter().flat_map(|(_, on)| on.iter().flatten().cloned()).collect();
2781 let missing: BTreeSet<String> = refs::hosts().iter().filter(|h| !on.contains(*h)).cloned().collect();
2782 if missing.is_empty() {
2783 continue;
2784 }
2785 let names: Vec<&str> = alts.iter().map(|(id, _)| id.as_str()).collect();
2786 let hint = alts
2788 .iter()
2789 .filter_map(|(id, _)| id.strip_prefix('#').and_then(|t| refs::yog_tag_for("item", t)))
2790 .find(|y| refs::tag_hosts("item", y) == Some(None))
2791 .map(|y| format!(" — @tag:item:{y} is on every version and loader"))
2792 .unwrap_or_default();
2793 out.push((missing, format!("{} isn't there{hint}", names.join(" / "))));
2794 }
2795 out
2796}
2797
2798struct Defs {
2801 order: BTreeMap<String, Vec<String>>,
2802 sound_groups: Vec<yog_formats::def::SoundGroup>,
2803 tool_materials: Vec<yog_formats::def::ToolMaterial>,
2804 armor_materials: Vec<yog_formats::def::ArmorMaterial>,
2805 biome_features: Vec<(String, String, String)>,
2807 dimensions: Vec<String>,
2809 tables: Vec<table::Table>,
2811 loot_modifiers: Vec<String>,
2813 definitions: Vec<(&'static str, String)>,
2815 advancements: Vec<String>,
2817}
2818
2819impl Defs {
2820 fn level(&self, id: &str) -> Option<(&str, usize)> {
2822 self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
2823 }
2824}
2825
2826fn read_defs(defs: &[(String, String)], mod_ns: Option<&str>, errors: &mut Vec<String>) -> Defs {
2829 let mut order: BTreeMap<String, Vec<String>> = BTreeMap::new();
2830 let mut sound_groups: Vec<yog_formats::def::SoundGroup> = Vec::new();
2831 order.insert(yog_formats::def::TOOL_LADDER.into(), yog_formats::def::TOOL_LEVELS.iter().map(|l| format!("minecraft:{l}")).collect());
2832 let ns = mod_ns.unwrap_or("minecraft");
2833 let full = |id: &str| if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") };
2834 let mut levels: Vec<(String, usize, String, String, Option<String>)> = Vec::new();
2836 let mut tools: Vec<(String, yog_formats::def::ToolMaterialDef)> = Vec::new();
2837 let mut armor_materials: Vec<yog_formats::def::ArmorMaterial> = Vec::new();
2838 let repair = |r: &yog_data::ItemIdOrTag| match r {
2839 yog_data::OneOrTag::One(i) => i.id.clone(),
2840 yog_data::OneOrTag::Tag(t) => format!("#{}", t.id),
2841 };
2842 for (file, text) in defs {
2843 let doc = match yog_data::text::parse(text) {
2844 Ok(d) => d,
2845 Err(e) => {
2846 errors.push(format!("{file}:{}: {}", e.at.line, e.message));
2847 continue;
2848 }
2849 };
2850 match yog_data::schema::from_doc::<yog_formats::def::Def>(text, &doc) {
2851 Ok(r) => match r.value {
2852 yog_formats::def::Def::Ladder { id } => {
2853 if order.insert(full(&id), Vec::new()).is_some() {
2854 errors.push(format!("{file}:1: ladder {} is defined twice", full(&id)));
2855 }
2856 }
2857 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)),
2858 yog_formats::def::Def::SoundGroup(mut g) => {
2859 for s in [&mut g.id, &mut g.break_sound, &mut g.step, &mut g.place, &mut g.hit, &mut g.fall] {
2861 *s = full(s);
2862 }
2863 if yog_formats::def::SoundGroup::built_in(&g.id).is_some() {
2864 errors.push(format!("{file}:1: sound group {} is the game's", g.id));
2865 } else if sound_groups.iter().any(|x| x.id == g.id) {
2866 errors.push(format!("{file}:1: sound group {} is defined twice", g.id));
2867 } else {
2868 sound_groups.push(g);
2869 }
2870 }
2871 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(_) | yog_formats::def::Def::Attribute(_) | yog_formats::def::Def::Effect(_) | yog_formats::def::Def::Advancement(_) => {}
2873 yog_formats::def::Def::ToolMaterial(mut m) => {
2875 m.id = full(&m.id);
2876 tools.push((file.clone(), m));
2877 }
2878 yog_formats::def::Def::ArmorMaterial(m) => {
2879 let id = full(&m.id);
2880 if yog_formats::def::ArmorMaterial::built_in(&id).is_some() || armor_materials.iter().any(|x| x.id == id) {
2881 errors.push(format!("{file}:1: armor material {id} is the game's or defined twice"));
2882 continue;
2883 }
2884 armor_materials.push(yog_formats::def::ArmorMaterial {
2885 texture: m.texture.as_deref().map(full).unwrap_or_else(|| id.clone()),
2886 equip_sound: full(&m.equip_sound),
2887 durability: m.durability,
2888 defense: m.defense,
2889 enchantability: m.enchantability,
2890 toughness: m.toughness,
2891 knockback_resistance: m.knockback_resistance,
2892 repair: repair(&m.repair),
2893 id,
2894 });
2895 }
2896 },
2897 Err(es) => {
2898 for e in es.0 {
2899 let l = e.at.map_or(1, |a| a.line as usize);
2900 errors.push(if e.path.is_root() { format!("{file}:{l}: {}", e.message) } else { format!("{file}:{l}: {}: {}", e.path, e.message) });
2901 }
2902 }
2903 }
2904 }
2905 loop {
2907 let before = levels.len();
2908 levels.retain(|(file, line, id, ladder, above)| {
2909 let Some(list) = order.get_mut(ladder) else {
2910 errors.push(format!("{file}:{line}: level {id}: no ladder {ladder}"));
2911 return false;
2912 };
2913 let at = match above {
2914 None => 0,
2915 Some(a) => match list.iter().position(|x| x == a) {
2916 Some(i) => i + 1,
2917 None => return true, },
2919 };
2920 if list.contains(id) {
2921 errors.push(format!("{file}:{line}: level {id} is defined twice"));
2922 } else {
2923 list.insert(at, id.clone());
2924 }
2925 false
2926 });
2927 if levels.is_empty() || levels.len() == before {
2928 break;
2929 }
2930 }
2931 for (file, line, id, ladder, above) in levels {
2932 let above = above.unwrap_or_default();
2933 errors.push(format!("{file}:{line}: level {id}: above {above}, which isn't a level of {ladder} (or the levels go round in a circle)"));
2934 }
2935 let ladder = order.get(yog_formats::def::TOOL_LADDER).cloned().unwrap_or_default();
2940 let mut tool_materials: Vec<yog_formats::def::ToolMaterial> = Vec::new();
2941 for (file, m) in tools {
2942 if yog_formats::def::ToolMaterial::built_in(&m.id).is_some() || tool_materials.iter().any(|x| x.id == m.id) {
2943 errors.push(format!("{file}:1: tool material {} is the game's or defined twice", m.id));
2944 continue;
2945 }
2946 let Some(at) = ladder.iter().position(|l| l == &m.level) else {
2947 errors.push(format!("{file}:1: level: {} isn't a level of the tool ladder", m.level));
2948 continue;
2949 };
2950 let place = |v: &str| ladder.iter().position(|x| x == &format!("minecraft:{v}")).unwrap_or(0);
2951 let game = ["wood", "stone", "iron", "diamond", "netherite"];
2952 let (level_index, incorrect) = match game.iter().position(|v| place(v) >= at) {
2953 Some(i) => (i as u32, format!("minecraft:incorrect_for_{}_tool", if game[i] == "wood" { "wooden" } else { game[i] })),
2954 None => {
2956 let (n, p) = m.level.split_once(':').unwrap_or(("minecraft", &m.level));
2957 (5, format!("{n}:incorrect_for_{p}_tool"))
2958 }
2959 };
2960 tool_materials.push(yog_formats::def::ToolMaterial {
2961 id: m.id,
2962 durability: m.durability,
2963 speed: m.speed,
2964 attack_damage: m.attack_damage,
2965 enchantability: m.enchantability,
2966 level: m.level,
2967 repair: repair(&m.repair),
2968 level_index,
2969 incorrect,
2970 });
2971 }
2972 Defs { order, sound_groups, tool_materials, armor_materials, biome_features: Vec::new(), dimensions: Vec::new(), tables: Vec::new(), loot_modifiers: Vec::new(), definitions: Vec::new(), advancements: Vec::new() }
2973}
2974
2975fn tool_level_tags(id: &str, level: &str, ladders: &Defs, out: &mut Compiled, err: &mut dyn FnMut(String), warn: &mut dyn FnMut(String)) {
2980 let Some((ladder, at)) = ladders.level(level) else { return };
2981 if ladder != yog_formats::def::TOOL_LADDER {
2982 err(format!("{level} is a level of {ladder}, not of the tool ladder ({})", yog_formats::def::TOOL_LADDER));
2983 return;
2984 }
2985 let tools = &ladders.order[yog_formats::def::TOOL_LADDER];
2986 let place = |l: &str| tools.iter().position(|x| x == &format!("minecraft:{l}")).unwrap_or(0);
2987 let able = ["wood", "stone", "iron", "diamond", "netherite"].into_iter().find(|v| place(v) >= at);
2989 let mut tag = |path: String| {
2990 out.tags.entry(path).or_default().insert(id.to_string(), true);
2991 };
2992 match able {
2993 Some("wood") => {}
2994 Some(v @ ("stone" | "iron" | "diamond")) => tag(format!("data/minecraft/tags/block/needs_{v}_tool.json")),
2995 _ => {
2996 tag("data/minecraft/tags/block/needs_diamond_tool.json".into());
2998 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json".into());
2999 tag(format!("when/fabric/1.20/data/fabric/tags/block/needs_tool_level_{}.json", if able.is_some() { 4 } else { 5 }));
3000 for loader in ["forge", "neoforge"] {
3001 tag(format!("when/{loader}/1.20/data/forge/tags/block/needs_netherite_tool.json"));
3002 }
3003 if able.is_none() {
3004 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_netherite_tool.json".into());
3005 warn("above netherite: on Forge and NeoForge 1.20 a netherite tool still mines it (their tiers stop at netherite)".into());
3006 }
3007 }
3008 }
3009}
3010
3011fn inventory_screens(dir: &Path) -> BTreeSet<String> {
3014 let mut out = BTreeSet::new();
3015 let Ok(entries) = std::fs::read_dir(dir) else {
3016 return out;
3017 };
3018 for e in entries.flatten() {
3019 let path = e.path();
3020 if path.is_dir() {
3021 out.extend(inventory_screens(&path));
3022 continue;
3023 }
3024 if path.extension().is_none_or(|x| x != "yui") {
3025 continue;
3026 }
3027 let Ok(text) = std::fs::read_to_string(&path) else { continue };
3028 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
3029 continue;
3030 }
3031 if let Some(screen) = text.find("<Screen")
3032 && let Some(at) = text[screen..].find(" id=\"")
3033 {
3034 let rest = &text[screen + at + 5..];
3035 if let Some(end) = rest.find('"') {
3036 out.insert(rest[..end].to_string());
3037 }
3038 }
3039 }
3040 out
3041}
3042
3043pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
3046 let mut out = Compiled::default();
3047 let files = find(root);
3048 if files.is_empty() {
3049 return Ok(out);
3050 }
3051 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
3052 let mut errors: Vec<String> = Vec::new();
3053 let mut warnings: Vec<String> = Vec::new();
3054 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
3055 let mut generated: BTreeMap<String, String> = BTreeMap::new();
3056 let mut entries: Vec<Entry> = Vec::new();
3057 let mut ids: HashMap<String, String> = HashMap::new();
3058 let mut recipes: Vec<Recipe> = Vec::new();
3059 let mut recipe_ids: HashMap<String, String> = HashMap::new();
3060 let mut books: Vec<BookFile> = Vec::new();
3061 let mut book_ids: HashMap<String, String> = HashMap::new();
3062 let mut bodies: Vec<BodyFile> = Vec::new();
3063 let mut fonts: Vec<FontFile> = Vec::new();
3064 let mut font_ids: HashMap<String, String> = HashMap::new();
3065 let mut entity_files: Vec<EntityFile> = Vec::new();
3066 let mut body_ids: HashMap<(bool, String), String> = HashMap::new();
3067 let mut inventories: Vec<(String, usize, String)> = Vec::new();
3068 let mut pending_books: Vec<(String, String)> = Vec::new();
3069 let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
3070 let mut pending_layouts: Vec<(String, String)> = Vec::new();
3071 let mut pending_defs: Vec<(String, String)> = Vec::new();
3072 let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
3073 for path in &files {
3074 let shown = display(root, path);
3075 let Some(kind) = Kind::of(path) else { continue };
3076 let text = match std::fs::read_to_string(path) {
3077 Ok(t) => t,
3078 Err(e) => {
3079 errors.push(format!("{shown}: {e}"));
3080 continue;
3081 }
3082 };
3083 if matches!(kind, Kind::Model | Kind::Anim) {
3084 if let Some(b) = compile_body(&shown, &text, kind, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
3085 if let Some(other) = body_ids.insert((kind == Kind::Model, b.id.clone()), shown.clone()) {
3086 errors.push(format!("{shown}: {} {} is also defined by {other}", kind.word(), b.id));
3087 }
3088 bodies.push(b);
3089 }
3090 continue;
3091 }
3092 if kind == Kind::Font {
3093 if let Some(font) = compile_font(&shown, &text, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
3094 if let Some(other) = font_ids.insert(font.id.clone(), shown.clone()) {
3095 errors.push(format!("{shown}: font {} is also defined by {other}", font.id));
3096 }
3097 fonts.push(font);
3098 }
3099 continue;
3100 }
3101 if kind == Kind::Book {
3102 pending_books.push((shown.clone(), text));
3104 continue;
3105 }
3106 if kind == Kind::Recipe {
3107 pending_recipes.push((shown.clone(), text, path.clone()));
3109 continue;
3110 }
3111 if kind == Kind::Def {
3112 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));
3114 if matches!(of.as_deref(), Some("recipe_kind" | "ore" | "dimension" | "table" | "tag" | "damage_type" | "tree" | "loot_modifier" | "loot_table" | "attribute" | "effect" | "advancement")) {
3115 pending_layouts.push((shown.clone(), text));
3116 } else {
3117 pending_defs.push((shown.clone(), text));
3118 }
3119 continue;
3120 }
3121 let mut cx = Ctx {
3122 root,
3123 refs,
3124 file: shown.clone(),
3125 ns: mod_ns.unwrap_or("minecraft").to_string(),
3126 errors: &mut errors,
3127 warnings: &mut warnings,
3128 textures: &mut textures,
3129 inventories: &mut inventories,
3130 deferred: &mut deferred,
3131 };
3132 if kind == Kind::Entity {
3133 if let Some(e) = compile_entity(&mut cx, &text, path, mod_ns, &mut out) {
3134 if let Some(other) = ids.insert(e.id.clone(), shown.clone()) {
3135 errors.push(format!("{shown}: id {} is also defined by {other}", e.id));
3136 }
3137 entity_files.push(e);
3138 }
3139 continue;
3140 }
3141 if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
3142 {
3143 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
3144 errors.push(format!(
3145 "{shown}: id {} is also defined by {other}",
3146 entry.id
3147 ));
3148 }
3149 entries.push(entry);
3150 }
3151 }
3152 let mut potion_defs: BTreeMap<String, yog_registry::PotionDef> = entries
3153 .iter()
3154 .filter_map(|e| e.potion.clone().map(|p| (e.id.clone(), p)))
3155 .collect();
3156 entries.retain(|e| e.potion.is_none());
3157 let manifest = crate::manifest::YogToml::read(&root.join("yog.toml")).ok();
3159 let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
3160 let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
3161 let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
3162 for e in &entries {
3163 known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
3164 }
3165 for id in potion_defs.keys() {
3166 known.add(refs::RefKind::Item, id.clone());
3167 }
3168 for e in &entity_files {
3169 known.add(refs::RefKind::Entity, e.id.clone());
3170 for (wanted, model, what) in [(&e.model, true, "model"), (&e.animations, false, "animations")] {
3172 if let Some(id) = wanted
3173 && mod_ns.is_some_and(|ns| id.starts_with(&format!("{ns}:")))
3174 && !bodies.iter().any(|b| b.model == model && &b.id == id)
3175 {
3176 errors.push(format!("{}: {what} `{id}`: no such file in content/ (`{}`)", e.file, if model { ".ymodel" } else { ".yanim" }));
3177 }
3178 }
3179 }
3180 let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
3183 let mut ores: Vec<OreOut> = Vec::new();
3184 let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
3185 let mut tables: Vec<table::Table> = Vec::new();
3186 let mut loot: Vec<(String, Vec<u8>)> = Vec::new();
3188 let mut definitions: Vec<(&'static str, String, Vec<u8>)> = Vec::new();
3190 let mut brews: Vec<(String, yog_registry::Brew)> = Vec::new();
3191 let mut advancements: Vec<String> = Vec::new();
3192 for (shown, text) in &pending_layouts {
3193 let mut cx = Ctx {
3194 root,
3195 refs,
3196 file: shown.clone(),
3197 ns: mod_ns.unwrap_or("minecraft").to_string(),
3198 errors: &mut errors,
3199 warnings: &mut warnings,
3200 textures: &mut textures,
3201 inventories: &mut inventories,
3202 deferred: &mut deferred,
3203 };
3204 let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables, &mut loot, &mut definitions, &mut advancements, &mut out) else { continue };
3205 if recipe_defs.contains_key(&k.id) {
3206 errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
3207 }
3208 recipe_defs.insert(k.id.clone(), k);
3209 }
3210 let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
3212 for (shown, text, path) in &pending_recipes {
3213 let mut cx = Ctx {
3214 root,
3215 refs,
3216 file: shown.clone(),
3217 ns: mod_ns.unwrap_or("minecraft").to_string(),
3218 errors: &mut errors,
3219 warnings: &mut warnings,
3220 textures: &mut textures,
3221 inventories: &mut inventories,
3222 deferred: &mut deferred,
3223 };
3224 if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs, &mut brews) {
3225 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
3226 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
3227 }
3228 recipes.push(r);
3229 }
3230 }
3231 for r in &recipes {
3232 known.add(refs::RefKind::Recipe, r.id.clone());
3233 }
3234 for (to, brew) in brews {
3235 if !potion_defs.contains_key(&brew.from) && !refs::is_vanilla_potion(&brew.from) {
3236 errors.push(format!("brewing recipe input `{}`: no potion `.yitem` or Yog default potion alias defines it", brew.from));
3237 continue;
3238 }
3239 if let Some(potion) = potion_defs.get_mut(&to) {
3240 potion.brewed.push(brew);
3241 } else if refs::is_vanilla_potion(&to) {
3242 potion_defs
3243 .entry(to.clone())
3244 .or_insert_with(|| yog_registry::PotionDef { id: to, name: None, effects: Vec::new(), brewed: Vec::new() })
3245 .brewed
3246 .push(brew);
3247 } else {
3248 errors.push(format!("brewing recipe result `{to}`: no potion `.yitem` or Yog default potion alias defines it"));
3249 }
3250 }
3251 let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
3253 definitions.extend(potion_defs.into_iter().map(|(id, potion)| ("potion", id, yog_data::binary::encode(&potion.write()))));
3254 for (files, feature) in ores {
3255 out.outputs.extend(files);
3256 defs.biome_features.push(feature);
3257 }
3258 for (file, line, path, r) in &deferred {
3260 let found = match r.kind.as_str() {
3261 "level" => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
3262 "ladder" => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
3263 "tool_material" => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
3264 .then_some(())
3265 .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
3266 "armor_material" => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
3267 .then_some(())
3268 .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
3269 "sound_group" => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
3270 .then_some(())
3271 .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
3272 kind if kind.starts_with(TAG_REF) => {
3273 let mut found = kind[TAG_REF.len()..].split('|').map(|k| known.check_tag(k, &r.id));
3275 let first = found.next().unwrap_or(Ok(()));
3276 if first.is_ok() || found.any(|f| f.is_ok()) { Ok(()) } else { first }
3277 }
3278 kind => match refs::RefKind::from_word(kind) {
3279 Some(k) => known.check(k, &r.id),
3280 None => Ok(()),
3281 },
3282 };
3283 if let Err(e) = found {
3284 errors.push(format!("{file}:{line}: {path}: {e}"));
3285 }
3286 }
3287 for e in &entries {
3288 if let Some((level, line)) = &e.block.tool_level {
3289 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)));
3290 }
3291 }
3292 let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
3293 let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
3295 for (shown, text) in &pending_books {
3296 let mut cx = Ctx {
3297 root,
3298 refs,
3299 file: shown.clone(),
3300 ns: mod_ns.unwrap_or("minecraft").to_string(),
3301 errors: &mut errors,
3302 warnings: &mut warnings,
3303 textures: &mut textures,
3304 inventories: &mut inventories,
3305 deferred: &mut deferred,
3306 };
3307 if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
3308 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
3309 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
3310 }
3311 books.push(b);
3312 }
3313 }
3314 if let Some(ns) = mod_ns {
3317 let screens = inventory_screens(&root.join("src"));
3318 for (file, line, id) in &inventories {
3319 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
3320 errors.push(format!(
3321 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
3322 ));
3323 }
3324 }
3325 }
3326 for (target, source) in &textures {
3327 match std::fs::read(source) {
3328 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
3329 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
3330 }
3331 }
3332 out.warnings = warnings;
3333 if !errors.is_empty() {
3334 return Err(format!(
3335 "invalid blocks/items in content/:\n {}",
3336 errors.join("\n ")
3337 ));
3338 }
3339 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
3340 out.items = entries.len() - out.blocks;
3341 out.recipes = recipes.len();
3342 out.books = books.len();
3343 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entity_files.is_empty() || !fonts.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() || !definitions.is_empty())
3344 && !mentions_content_macro(&root.join("src"))
3345 {
3346 out.warnings.push(format!(
3347 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
3348 CONTENT_DIR
3349 ));
3350 }
3351 out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
3352 out.data_files.extend(bodies.iter().map(|b| (b.path(), b.data.clone())));
3353 out.data_files.extend(fonts.iter().map(|f| (f.path(), f.data.clone())));
3354 out.data_files.extend(entity_files.iter().map(|e| (e.path(), e.data.clone())));
3355 out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
3356 out.data_files.extend(loot.iter().map(|(id, data)| (loot_path(id), data.clone())));
3357 out.data_files.extend(definitions.iter().map(|(kind, id, data)| (definition_path(kind, id), data.clone())));
3358 defs.definitions = definitions.iter().map(|(kind, id, _)| (*kind, id.clone())).collect();
3359 defs.loot_modifiers = loot.into_iter().map(|(id, _)| id).collect();
3360 defs.advancements = advancements;
3361 for m in &defs.tool_materials {
3363 if let Some((n, p)) = m.incorrect.split_once(':')
3364 && n != "minecraft"
3365 {
3366 out.tags.entry(format!("when/any/1.21/data/{n}/tags/block/{p}.json")).or_default();
3367 }
3368 }
3369 defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
3370 defs.tables = tables;
3371 out.source = Some(rust_source(&entries, &recipes, &books, &bodies, &entity_files, &fonts, &defs));
3372 version_overlays(&mut out, &known.datapack);
3373 out.registrations = registrations(&entries, &entity_files, &dimensions, &defs);
3374 Ok(out)
3375}
3376
3377fn registrations(entries: &[Entry], entities: &[EntityFile], dimensions: &[(String, Vec<u8>)], defs: &Defs) -> String {
3382 fn hash(text: &str) -> String {
3383 let h = text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
3385 format!("{h:016x}")
3386 }
3387 let mut lines = Vec::new();
3388 for e in entries {
3389 let what = match e.kind {
3390 Kind::Block => {
3391 let b = BlockProps { tools: Vec::new(), tool_level: None, ..e.block.clone() };
3393 format!("{b:?} {:?} {:?}", e.item, e.requires)
3394 }
3395 _ => format!("{:?} {:?}", e.item, e.requires),
3396 };
3397 lines.push(format!("{} {} {}", e.kind.word(), e.id, hash(&what)));
3398 }
3399 for e in entities {
3400 lines.push(format!("entity {} {}", e.id, hash(&format!("{:?}", e.data))));
3401 }
3402 for (id, data) in dimensions {
3403 lines.push(format!("dimension {id} {}", hash(&format!("{data:?}"))));
3404 }
3405 for g in &defs.sound_groups {
3406 lines.push(format!("sound_group {} {}", g.id, hash(&format!("{g:?}"))));
3407 }
3408 for m in &defs.tool_materials {
3409 lines.push(format!("tool_material {} {}", m.id, hash(&format!("{m:?}"))));
3410 }
3411 for m in &defs.armor_materials {
3412 lines.push(format!("armor_material {} {}", m.id, hash(&format!("{m:?}"))));
3413 }
3414 lines.sort();
3415 lines.join("\n") + "\n"
3416}
3417
3418fn version_overlays(out: &mut Compiled, datapack: &str) {
3423 fn map(v: &mut Value, version: &str, missing: &mut BTreeSet<String>) -> bool {
3424 match v {
3425 Value::String(s) if s.starts_with("minecraft:") => {
3426 let id = s.trim_start_matches('#').to_string();
3427 let eq = refs::equivalent(refs::RefKind::Item, &id, version).or_else(|| refs::equivalent(refs::RefKind::Block, &id, version));
3428 match eq {
3429 Some(e) if e != id => {
3430 *s = e;
3431 true
3432 }
3433 Some(_) => false,
3434 None => {
3436 if refs::is_vanilla(&id) {
3437 missing.insert(id);
3438 }
3439 false
3440 }
3441 }
3442 }
3443 Value::Array(a) => a.iter_mut().fold(false, |c, x| map(x, version, missing) | c),
3444 Value::Object(o) => o.values_mut().fold(false, |c, x| map(x, version, missing) | c),
3445 _ => false,
3446 }
3447 }
3448 let mut overlays = Vec::new();
3449 for (path, bytes) in &out.outputs {
3450 if !path.starts_with("data/") || !path.ends_with(".json") {
3451 continue;
3452 }
3453 let Ok(json) = serde_json::from_slice::<Value>(bytes) else { continue };
3454 for version in refs::versions().iter().filter(|v| *v != datapack) {
3455 let mut copy = json.clone();
3456 let mut missing = BTreeSet::new();
3457 if map(&mut copy, version, &mut missing) {
3458 overlays.push((format!("when/any/{version}/{path}"), pretty(©)));
3459 }
3460 for id in missing {
3461 out.warnings.push(format!("{path} names {id}, which Minecraft {version} hasn't got"));
3462 }
3463 }
3464 }
3465 out.outputs.extend(overlays);
3466}
3467
3468
3469fn mentions_content_macro(dir: &Path) -> bool {
3471 let Ok(entries) = std::fs::read_dir(dir) else {
3472 return false;
3473 };
3474 entries.flatten().any(|e| {
3475 let p = e.path();
3476 if p.is_dir() {
3477 mentions_content_macro(&p)
3478 } else {
3479 p.extension().is_some_and(|x| x == "rs")
3480 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
3481 }
3482 })
3483}
3484
3485fn rust_ident(path: &str) -> String {
3487 let mut s: String = path
3488 .chars()
3489 .map(|c| {
3490 if c.is_ascii_alphanumeric() {
3491 c.to_ascii_lowercase()
3492 } else {
3493 '_'
3494 }
3495 })
3496 .collect();
3497 if s.starts_with(|c: char| c.is_ascii_digit()) {
3498 s.insert(0, '_');
3499 }
3500 s
3501}
3502
3503fn float(n: f64) -> String {
3504 let s = format!("{n:?}");
3505 if s.contains('.') || s.contains('e') {
3506 s
3507 } else {
3508 format!("{s}.0")
3509 }
3510}
3511
3512fn when_loaded(requires: &[String], stmt: &str) -> String {
3514 if requires.is_empty() {
3515 return stmt.to_string();
3516 }
3517 let cond: Vec<String> = requires.iter().map(|m| format!("::yog_api::mod_loaded({m:?})")).collect();
3518 format!("if {} {{ {stmt} }}", cond.join(" && "))
3519}
3520
3521const GENERATED_HEADER: &str = "// <auto-generated>\n// Generated by `yog build` from content/** — don't edit, it's rewritten\n// on every build. Included by `yog_api::content!()`.\n// </auto-generated>\n";
3523
3524pub const GENERATED_PARTS_DIR: &str = "content";
3527
3528#[derive(Debug, Clone, Default)]
3531pub struct Source {
3532 pub root: String,
3533 pub parts: Vec<(String, String)>,
3534}
3535
3536impl Source {
3537 #[cfg(test)]
3539 pub fn part(&self, name: &str) -> &str {
3540 self.parts.iter().find(|(n, _)| n == name).map_or("", |(_, c)| c.as_str())
3541 }
3542
3543 #[cfg(test)]
3545 pub fn all(&self) -> String {
3546 self.parts.iter().map(|(_, c)| c.as_str()).collect()
3547 }
3548}
3549
3550fn flush(parts: &mut Vec<(String, String)>, code: &mut String, name: &str) {
3552 if !code.trim().is_empty() {
3553 parts.push((name.to_string(), format!("{GENERATED_HEADER}{}", std::mem::take(code))));
3554 }
3555 code.clear();
3556}
3557
3558fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile], bodies: &[BodyFile], entities: &[EntityFile], fonts: &[FontFile], defs: &Defs) -> Source {
3559 let sound_groups = &defs.sound_groups;
3560 let mut files: Vec<(String, String)> = Vec::new();
3561 let mut code = String::new();
3562 if !entities.is_empty() {
3563 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");
3564 let mut used: HashMap<String, usize> = HashMap::new();
3565 for e in entities {
3566 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3567 let mut ident = rust_ident(path).to_ascii_uppercase();
3568 let n = used.entry(ident.clone()).or_insert(0);
3569 *n += 1;
3570 if *n > 1 {
3571 let _ = write!(ident, "_{n}");
3572 }
3573 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", e.id);
3574 }
3575 code.push_str("}\n");
3576 flush(&mut files, &mut code, "entities");
3577 }
3578 if !defs.advancements.is_empty() {
3579 code.push_str("\n/// The advancements of `content/` (`.ydef` kind `advancement`), by id\n/// (`yog_api::advancements::grant`/`done`).\n#[allow(dead_code)]\npub struct Advancements;\n\n#[allow(dead_code, clippy::all)]\nimpl Advancements {\n");
3580 let mut used: HashMap<String, usize> = HashMap::new();
3581 for id in &defs.advancements {
3582 let path = id.split_once(':').map_or(id.as_str(), |(_, p)| p);
3583 let mut ident = rust_ident(path).to_ascii_uppercase();
3584 let n = used.entry(ident.clone()).or_insert(0);
3585 *n += 1;
3586 if *n > 1 {
3587 let _ = write!(ident, "_{n}");
3588 }
3589 let _ = writeln!(code, " pub const {ident}: &'static str = {id:?};");
3590 }
3591 code.push_str("}\n");
3592 flush(&mut files, &mut code, "advancements");
3593 }
3594 for (model, set, what) in [(true, "Models", "models (`.ymodel`)"), (false, "Anims", "animations (`.yanim`)")] {
3595 let mine: Vec<&BodyFile> = bodies.iter().filter(|b| b.model == model).collect();
3596 if mine.is_empty() {
3597 continue;
3598 }
3599 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} {{");
3600 let mut used: HashMap<String, usize> = HashMap::new();
3601 for b in mine {
3602 let path = b.id.split_once(':').map_or(b.id.as_str(), |(_, p)| p);
3603 let mut ident = rust_ident(path).to_ascii_uppercase();
3604 let n = used.entry(ident.clone()).or_insert(0);
3605 *n += 1;
3606 if *n > 1 {
3607 let _ = write!(ident, "_{n}");
3608 }
3609 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", b.id);
3610 }
3611 code.push_str("}\n");
3612 flush(&mut files, &mut code, &set.to_ascii_lowercase());
3613 }
3614 for (kind, set, def) in [
3615 (Kind::Block, "Blocks", "BlockDef"),
3616 (Kind::Item, "Items", "ItemDef"),
3617 ] {
3618 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
3619 let _ = writeln!(
3620 code,
3621 "\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} {{",
3622 if kind == Kind::Block {
3623 "blocks"
3624 } else {
3625 "items"
3626 }
3627 );
3628 let mut used: HashMap<String, usize> = HashMap::new();
3629 for e in mine {
3630 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3631 let mut ident = rust_ident(path);
3632 let n = used.entry(ident.clone()).or_insert(0);
3633 *n += 1;
3634 if *n > 1 {
3635 let _ = write!(ident, "_{n}");
3636 }
3637 let upper = ident.to_ascii_uppercase();
3638 let ident = if is_keyword(&ident) {
3639 format!("r#{ident}")
3640 } else {
3641 ident
3642 };
3643 let _ = writeln!(
3644 code,
3645 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
3646 e.id, e.file, e.id
3647 );
3648 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
3649 match kind {
3650 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity | Kind::Font => unreachable!("only blocks and items get here"),
3651 Kind::Block => {
3652 let p = &e.block;
3653 if p.hardness.is_some() || p.resistance.is_some() {
3654 let _ = write!(
3655 b,
3656 "\n .strength({}, {})",
3657 float(p.hardness.unwrap_or(1.5)),
3658 float(p.resistance.unwrap_or(6.0))
3659 );
3660 }
3661 if let Some(s) = &p.sound {
3662 let _ = write!(b, "\n .sound({s:?})");
3663 }
3664 if p.light > 0 {
3665 let _ = write!(b, "\n .light_level({})", p.light);
3666 }
3667 if p.requires_tool {
3668 b.push_str("\n .requires_tool()");
3669 }
3670 if p.no_collision {
3671 b.push_str("\n .no_collision()");
3672 }
3673 if let Some(s) = p.slipperiness {
3674 let _ = write!(b, "\n .slipperiness({})", float(s));
3675 }
3676 if let Some(s) = p.shape {
3677 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
3678 let _ = write!(b, "\n .shape({})", args.join(", "));
3679 }
3680 if p.connects {
3681 b.push_str("\n .connects_to_neighbors()");
3682 }
3683 if !p.connect_groups.is_empty() {
3684 let groups: Vec<String> =
3685 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
3686 let _ =
3687 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
3688 }
3689 if let Some(inv) = &p.inventory {
3690 let _ = write!(b, "\n .inventory({inv:?})");
3691 }
3692 if p.redstone {
3693 b.push_str("\n .redstone_source()");
3694 }
3695 if !p.facing.is_empty() {
3696 let _ = write!(b, "\n .facing({:?}, {:?})", p.facing, p.placement);
3697 }
3698 if p.attach {
3699 b.push_str("\n .attach()");
3700 }
3701 if p.waterlogged {
3702 b.push_str("\n .waterlogged()");
3703 }
3704 for (name, values) in &p.properties {
3705 let vs: Vec<String> = values.iter().map(|v| format!("{v:?}")).collect();
3706 let _ = write!(b, "\n .property({name:?}, &[{}])", vs.join(", "));
3707 }
3708 if let Some(name) = &p.powered {
3709 let _ = write!(b, "\n .powered({name:?})");
3710 }
3711 if let Some(name) = &p.comparator {
3712 let _ = write!(b, "\n .comparator({name:?})");
3713 }
3714 if p.accepts_items {
3715 b.push_str("\n .accepts_items()");
3716 }
3717 if p.no_occlusion {
3718 b.push_str("\n .no_occlusion()");
3719 }
3720 if !p.kind.is_empty() {
3721 let _ = write!(b, "\n .kind({:?})", p.kind);
3722 }
3723 if let Some(base) = &p.base {
3724 let _ = write!(b, "\n .base({base:?})");
3725 }
3726 if let Some(set) = &p.set {
3727 let _ = write!(b, "\n .set({set:?})");
3728 }
3729 if let Some(t) = p.press_ticks {
3730 let _ = write!(b, "\n .press_ticks({t})");
3731 }
3732 if p.mobs_only {
3733 b.push_str("\n .mobs_only()");
3734 }
3735 }
3736 Kind::Item => b.push_str(&item_props(&e.item)),
3737 }
3738 let _ = writeln!(
3739 code,
3740 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
3741 e.file
3742 );
3743 }
3744 code.push_str("}\n");
3745 flush(&mut files, &mut code, &set.to_ascii_lowercase());
3746 }
3747
3748 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");
3749 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
3750 for e in entries {
3751 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3752 let mut ident = rust_ident(path);
3753 let n = used.entry((e.kind, ident.clone())).or_insert(0);
3754 *n += 1;
3755 if *n > 1 {
3756 let _ = write!(ident, "_{n}");
3757 }
3758 let ident = if is_keyword(&ident) {
3759 format!("r#{ident}")
3760 } else {
3761 ident
3762 };
3763 if !e.auto {
3764 continue;
3765 }
3766 let _ = match e.kind {
3767 Kind::Block => {
3768 let item = item_props(&e.item);
3770 let stmt = if item.is_empty() {
3771 format!("registry.register_block(Blocks::{ident}());")
3772 } else {
3773 format!("{{ registry.register_block(Blocks::{ident}()); registry.register_item(::yog_api::ItemDef::new({:?}){}); }}", e.id, item.replace("\n ", ""))
3774 };
3775 writeln!(code, " {}", when_loaded(&e.requires, &stmt))
3776 }
3777 Kind::Item => writeln!(code, " {}", when_loaded(&e.requires, &format!("registry.register_item(Items::{ident}());"))),
3778 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity | Kind::Font => unreachable!("only blocks and items get here"),
3779 };
3780 }
3781 for g in sound_groups {
3782 let _ = writeln!(
3783 code,
3784 " registry.register_sound_group(::yog_api::SoundGroup::new({:?}).sounds({:?}, {:?}, {:?}, {:?}, {:?}).volume({}).pitch({}));",
3785 g.id,
3786 g.break_sound,
3787 g.step,
3788 g.place,
3789 g.hit,
3790 g.fall,
3791 float(g.volume as f64),
3792 float(g.pitch as f64)
3793 );
3794 }
3795 for id in &defs.dimensions {
3796 let _ = writeln!(
3797 code,
3798 " registry.register_dimension_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3799 dimension_path(id)
3800 );
3801 }
3802 for (feature, biomes, step) in &defs.biome_features {
3803 let _ = writeln!(code, " registry.add_biome_feature({feature:?}, {biomes:?}, {step:?});");
3804 }
3805 for m in &defs.tool_materials {
3806 let _ = writeln!(
3807 code,
3808 " registry.register_tool_material(::yog_api::ToolMaterial {{ id: {:?}.into(), durability: {}, speed: {:?}, attack_damage: {:?}, enchantability: {}, level: {:?}.into(), repair: {:?}.into(), level_index: {}, incorrect: {:?}.into() }});",
3809 m.id, m.durability, m.speed, m.attack_damage, m.enchantability, m.level, m.repair, m.level_index, m.incorrect
3810 );
3811 }
3812 for m in &defs.armor_materials {
3813 let d = m.defense;
3814 let _ = writeln!(
3815 code,
3816 " 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() }});",
3817 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
3818 );
3819 }
3820 for r in recipes {
3821 let _ = writeln!(code, " {}", when_loaded(&r.requires, &r.registration()));
3822 }
3823 for b in books {
3824 let _ = writeln!(
3825 code,
3826 " registry.register_book_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3827 b.id,
3828 b.path()
3829 );
3830 }
3831 for (kind, id) in &defs.definitions {
3832 let _ = writeln!(
3833 code,
3834 " registry.register_definition_data({kind:?}, {id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3835 definition_path(kind, id)
3836 );
3837 }
3838 for id in &defs.loot_modifiers {
3839 let _ = writeln!(
3840 code,
3841 " registry.add_loot_modifier_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3842 loot_path(id)
3843 );
3844 }
3845 for e in entities {
3846 let _ = writeln!(
3847 code,
3848 " registry.register_entity_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3849 e.id,
3850 e.path()
3851 );
3852 }
3853 for b in bodies {
3854 let _ = writeln!(
3855 code,
3856 " registry.register_body_{}({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3857 if b.model { "model" } else { "anims" },
3858 b.id,
3859 b.path()
3860 );
3861 }
3862 for font in fonts {
3863 let _ = writeln!(
3864 code,
3865 " registry.register_font_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3866 font.id,
3867 font.path()
3868 );
3869 }
3870 code.push_str("}\n");
3871 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entities.is_empty() || !fonts.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() {
3872 code.push_str(
3873 "\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",
3874 );
3875 }
3876 flush(&mut files, &mut code, "register");
3877 for t in &defs.tables {
3878 code.push_str(&table::rust(t));
3879 }
3880 flush(&mut files, &mut code, "tables");
3881 let mut root = String::from(GENERATED_HEADER);
3882 for (name, _) in &files {
3883 let _ = writeln!(root, "include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{GENERATED_PARTS_DIR}/{name}.g.rs\"));");
3884 }
3885 Source { root, parts: files }
3886}
3887
3888fn is_keyword(s: &str) -> bool {
3889 const KW: &[&str] = &[
3890 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
3891 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
3892 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
3893 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
3894 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
3895 ];
3896 KW.contains(&s)
3897}
3898
3899pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
3902 write_tags(root, compiled)?;
3903 let out = root.join(GENERATED_FILE);
3904 let dir = out.parent().unwrap_or(root);
3905 let parts_dir = dir.join(GENERATED_PARTS_DIR);
3906 let Some(source) = &compiled.source else {
3907 let _ = std::fs::remove_file(&out);
3908 let _ = std::fs::remove_dir_all(&parts_dir);
3909 return Ok(());
3910 };
3911 std::fs::create_dir_all(&parts_dir).map_err(|e| format!("{}: {e}", parts_dir.display()))?;
3912 let wanted: Vec<std::path::PathBuf> = source.parts.iter().map(|(name, _)| parts_dir.join(format!("{name}.g.rs"))).collect();
3913 for ((_, code), file) in source.parts.iter().zip(&wanted) {
3914 crate::write_if_changed(file, code)?;
3915 }
3916 for entry in std::fs::read_dir(&parts_dir).map_err(|e| format!("{}: {e}", parts_dir.display()))?.flatten() {
3918 if !wanted.contains(&entry.path()) {
3919 let _ = std::fs::remove_file(entry.path());
3920 }
3921 }
3922 crate::write_if_changed(&out, &source.root)?;
3923 for (path, bytes) in &compiled.data_files {
3924 let file = dir.join(path);
3925 if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
3926 if let Some(parent) = file.parent() {
3927 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
3928 }
3929 std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
3930 }
3931 }
3932 Ok(())
3933}
3934
3935pub const TAGS_FILE: &str = ".yog-build/generated/tags.json";
3938
3939fn write_tags(root: &Path, compiled: &Compiled) -> Result<(), String> {
3941 let mut by_kind: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
3942 for (path, entries) in &compiled.tags {
3943 let data = path.find("data/").map_or(path.as_str(), |i| &path[i + 5..]);
3945 let Some((ns, rest)) = data.split_once("/tags/") else { continue };
3946 let Some((kind, tag)) = rest.split_once('/') else { continue };
3947 let tag = tag.trim_end_matches(".json");
3948 by_kind.entry(kind.to_string()).or_default().entry(format!("{ns}:{tag}")).or_default().extend(entries.keys().cloned());
3949 }
3950 let text = serde_json::to_string_pretty(&by_kind).map_err(|e| e.to_string())?;
3951 crate::write_if_changed(&root.join(TAGS_FILE), &text)
3952}
3953
3954#[derive(Default)]
3957pub struct OutputSet {
3958 items: Vec<Option<Output>>,
3959 at: HashMap<String, usize>,
3960}
3961
3962impl OutputSet {
3963 pub fn get(&self, path: &str) -> Option<&[u8]> {
3964 let i = *self.at.get(path)?;
3965 self.items[i].as_ref().map(|(_, b)| b.as_slice())
3966 }
3967
3968 pub fn contains(&self, path: &str) -> bool {
3969 self.at.contains_key(path)
3970 }
3971
3972 pub fn paths(&self) -> impl Iterator<Item = &String> {
3973 self.at.keys()
3974 }
3975
3976 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
3978 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
3979 self.items[i] = None;
3980 }
3981 self.items.push(Some((path, bytes)));
3982 }
3983
3984 pub fn into_vec(self) -> Vec<Output> {
3985 self.items.into_iter().flatten().collect()
3986 }
3987}
3988
3989impl From<Vec<Output>> for OutputSet {
3990 fn from(outputs: Vec<Output>) -> Self {
3991 let mut set = OutputSet::default();
3992 for (path, bytes) in outputs {
3993 set.put(path, bytes);
3994 }
3995 set
3996 }
3997}
3998
3999fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
4002 let read;
4003 let bytes = match outputs.get(path) {
4004 Some(b) => b,
4005 None => match std::fs::read(root.join(path)) {
4006 Ok(b) => {
4007 read = b;
4008 &read
4009 }
4010 Err(_) => return Ok(Map::new()),
4011 },
4012 };
4013 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
4014}
4015
4016pub fn merge_outputs(
4020 root: &Path,
4021 outputs: &mut Vec<Output>,
4022 compiled: Compiled,
4023) -> Result<(), String> {
4024 let mut set = OutputSet::from(std::mem::take(outputs));
4025 let mut errors = Vec::new();
4026 if !compiled.lang_aliases.is_empty() {
4029 let mut lang_files: BTreeSet<String> =
4030 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
4031 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
4032 for ns in dirs.flatten() {
4033 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
4034 for f in files.flatten() {
4035 if let Ok(rel) = f.path().strip_prefix(root) {
4036 let rel = rel.to_string_lossy().replace('\\', "/");
4037 if is_lang_path(&rel) {
4038 lang_files.insert(rel);
4039 }
4040 }
4041 }
4042 }
4043 }
4044 }
4045 for path in lang_files {
4046 let mut map = match json_output(&set, root, &path) {
4047 Ok(m) => m,
4048 Err(e) => {
4049 errors.push(e);
4050 continue;
4051 }
4052 };
4053 let mut changed = false;
4054 for (from, to) in &compiled.lang_aliases {
4055 if let Some(text) = map.get(from).cloned()
4056 && !map.contains_key(to)
4057 {
4058 map.insert(to.clone(), text);
4059 changed = true;
4060 }
4061 }
4062 if changed {
4063 set.put(path, pretty(&Value::Object(map)));
4064 }
4065 }
4066 }
4067 for ((ns, locale), entries) in &compiled.lang {
4068 let path = format!("assets/{ns}/lang/{locale}.json");
4069 let mut map = match json_output(&set, root, &path) {
4070 Ok(m) => m,
4071 Err(e) => {
4072 errors.push(e);
4073 continue;
4074 }
4075 };
4076 for (k, v) in entries {
4077 map.entry(k.clone())
4078 .or_insert_with(|| Value::String(v.clone()));
4079 }
4080 set.put(path, pretty(&Value::Object(map)));
4081 }
4082 for (path, ids) in &compiled.tags {
4083 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
4086 let path = if !set.contains(path) && root.join(&plural).is_file() {
4087 plural
4088 } else {
4089 path.clone()
4090 };
4091 let mut map = match json_output(&set, root, &path) {
4092 Ok(m) => m,
4093 Err(e) => {
4094 errors.push(e);
4095 continue;
4096 }
4097 };
4098 let values = map
4099 .entry("values")
4100 .or_insert_with(|| Value::Array(Vec::new()));
4101 if let Value::Array(list) = values {
4102 for (id, &required) in ids {
4103 let listed = |v: &Value| v.as_str().or_else(|| v.get("id").and_then(Value::as_str)) == Some(id.as_str());
4104 if !list.iter().any(listed) {
4105 list.push(if required { Value::String(id.clone()) } else { serde_json::json!({ "id": id, "required": false }) });
4106 }
4107 }
4108 }
4109 map.entry("replace").or_insert(Value::Bool(false));
4110 set.put(path, pretty(&Value::Object(map)));
4111 }
4112 for (path, bytes) in compiled.outputs {
4113 set.put(path, bytes);
4114 }
4115 *outputs = set.into_vec();
4116 if errors.is_empty() {
4117 Ok(())
4118 } else {
4119 Err(format!(
4120 "merging blocks/items into the mod's files:\n {}",
4121 errors.join("\n ")
4122 ))
4123 }
4124}
4125
4126fn is_lang_path(p: &str) -> bool {
4127 let parts: Vec<&str> = p.split('/').collect();
4128 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
4129}
4130
4131#[cfg(test)]
4132mod tests {
4133 use super::*;
4134
4135 fn project(files: &[(&str, &str)]) -> PathBuf {
4136 use std::sync::atomic::{AtomicUsize, Ordering};
4137 static N: AtomicUsize = AtomicUsize::new(0);
4138 let dir = std::env::temp_dir().join(format!(
4139 "yog-content-test-{}-{}",
4140 std::process::id(),
4141 N.fetch_add(1, Ordering::SeqCst)
4142 ));
4143 let _ = std::fs::remove_dir_all(&dir);
4144 for (path, text) in files {
4145 let p = dir.join(path);
4146 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
4147 std::fs::write(p, text).unwrap();
4148 }
4149 dir
4150 }
4151
4152 fn output(c: &Compiled, path: &str) -> Value {
4153 let (_, b) = c
4154 .outputs
4155 .iter()
4156 .find(|(p, _)| p == path)
4157 .unwrap_or_else(|| {
4158 panic!(
4159 "no {path} in {:?}",
4160 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
4161 )
4162 });
4163 serde_json::from_slice(b).unwrap()
4164 }
4165
4166 #[test]
4167 fn compiles_a_cube_block() {
4168 let root = project(&[
4169 ("resources/block/ruby_ore.png", "png"),
4170 ("content/ruby.yitem", r#"{ model: { parent: "generated", textures: { layer0: "minecraft:item/emerald" } } }"#),
4171 (
4172 "content/blocks/ruby_ore.yblock",
4173 r#"{
4174 "name": "Ruby Ore",
4175 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
4176 "drops": { "item": "@item:mymod:ruby", "count": [1, 3] } },
4177 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
4178}"#,
4179 ),
4180 ]);
4181 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4182 assert_eq!(c.blocks, 1);
4183 assert_eq!(
4184 output(&c, "assets/mymod/models/block/ruby_ore.json"),
4185 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
4186 );
4187 assert_eq!(
4188 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
4189 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
4190 );
4191 assert_eq!(
4192 output(&c, "assets/mymod/models/item/ruby_ore.json"),
4193 json!({ "parent": "mymod:block/ruby_ore" })
4194 );
4195 assert_eq!(
4196 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
4197 json!("mymod:item/ruby_ore")
4198 );
4199 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
4200 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
4201 assert_eq!(
4202 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
4203 json!(3)
4204 );
4205 assert!(
4206 c.outputs
4207 .iter()
4208 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
4209 );
4210 let src = &c.source.as_ref().unwrap().all();
4211 assert!(
4212 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
4213 "{src}"
4214 );
4215 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
4216 assert!(src.contains(".requires_tool()"), "{src}");
4217 assert!(
4218 src.contains("registry.register_block(Blocks::ruby_ore());"),
4219 "{src}"
4220 );
4221
4222 let mut outputs = Vec::new();
4223 merge_outputs(&root, &mut outputs, c).unwrap();
4224 let find = |p: &str| -> Value {
4225 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
4226 };
4227 assert_eq!(
4228 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
4229 json!("Ruby Ore")
4230 );
4231 assert_eq!(
4232 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
4233 json!(["mymod:ruby_ore"])
4234 );
4235 assert_eq!(
4236 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
4237 json!(["mymod:ruby_ore"])
4238 );
4239 let _ = std::fs::remove_dir_all(root);
4240 }
4241
4242 #[test]
4243 fn raw_parent_with_elements_gets_a_particle() {
4244 let root = project(&[(
4245 "content/cable.yblock",
4246 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
4247 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
4248 )]);
4249 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4250 let m = output(&c, "assets/mymod/models/block/cable.json");
4251 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
4252 let _ = std::fs::remove_dir_all(root);
4253 }
4254
4255 #[test]
4256 fn a_facing_block_turns_to_its_placer() {
4257 let root = project(&[(
4258 "content/screen.yblock",
4259 r##"{ "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } },
4260 "states": { "facing": "horizontal" } }"##,
4261 )]);
4262 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4263 let bs = output(&c, "assets/mymod/blockstates/screen.json");
4264 assert_eq!(bs["variants"]["facing=north"], json!({ "model": "mymod:block/screen" }));
4265 assert_eq!(bs["variants"]["facing=west"]["y"], json!(270));
4266 assert!(c.source.unwrap().all().contains(".facing(\"horizontal\", \"toward\")"));
4267 let _ = std::fs::remove_dir_all(root);
4268 }
4269
4270 #[test]
4271 fn compiles_elements_shape_and_connect() {
4272 let root = project(&[(
4273 "content/pipe.yblock",
4274 r##"{
4275 "block": { "render_layer": "cutout" },
4276 "model": {
4277 "textures": { "0": "minecraft:block/stone" },
4278 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
4279 "rotation": { "axis": "y", "angle": 22.5 },
4280 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
4281 },
4282 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
4283 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
4284}"##,
4285 )]);
4286 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4287 let m = output(&c, "assets/mymod/models/block/pipe.json");
4288 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
4289 assert_eq!(m["render_type"], json!("minecraft:cutout"));
4290 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
4291 assert_eq!(m["parent"], json!("minecraft:block/block"), "shown in a slot and held as a block");
4292 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
4293 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
4294 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
4295 let src = c.source.unwrap().all();
4296 assert!(
4297 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
4298 "{src}"
4299 );
4300 assert!(src.contains(".connects_to_neighbors()"), "{src}");
4301 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
4302 let _ = std::fs::remove_dir_all(root);
4303 }
4304
4305 #[test]
4306 fn sound_groups_are_the_games_or_a_mods_ydef() {
4307 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4308 let crystal = r#"{ kind: "sound_group", id: "crystal", break: "crystal.break", step: "minecraft:block.glass.step",
4309 place: "crystal.place", hit: "crystal.hit", fall: "crystal.fall", pitch: 1.5 }"#;
4310 let a = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:crystal" }} }}"#);
4311 let b = format!(r#"{{ {cube}, block: {{ sound: "amethyst" }} }}"#);
4312 let c = compile(&project(&[("content/a.yblock", &a), ("content/b.yblock", &b), ("content/crystal.ydef", crystal)]), "mymod", &yres::Refs::default()).unwrap();
4313 let src = c.source.unwrap().all();
4314 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}");
4315 assert!(src.contains(r#".sound("mymod:crystal")"#) && src.contains(r#".sound("minecraft:amethyst")"#), "{src}");
4316
4317 let bad = format!(r#"{{ {cube}, block: {{ sound: "crystal" }} }}"#);
4318 let missing = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:nope" }} }}"#);
4319 let e = compile(&project(&[("content/a.yblock", &bad), ("content/b.yblock", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4320 assert!(e.contains("`crystal`: not one of the game's sound groups"), "{e}");
4321 assert!(e.contains("no sound group mymod:nope"), "{e}");
4322 }
4323
4324 #[test]
4325 fn tools_are_tags_and_levels_are_definitions_a_mod_can_add() {
4326 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4327 let mythril = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
4328 let block = format!(r#"{{ {cube}, block: {{ tool: ["pickaxe", "@tag:block:mymod:mineable/hammer"], tool_level: "@level:mymod:mythril" }} }}"#);
4329 let c = compile(&project(&[("content/ore.yblock", &block), ("content/mythril.ydef", mythril)]), "mymod", &yres::Refs::default()).unwrap();
4330 for tag in [
4331 "data/minecraft/tags/block/mineable/pickaxe.json",
4332 "data/mymod/tags/block/mineable/hammer.json",
4333 "data/minecraft/tags/block/needs_diamond_tool.json",
4335 "when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json",
4336 "when/fabric/1.20/data/fabric/tags/block/needs_tool_level_4.json",
4337 "when/forge/1.20/data/forge/tags/block/needs_netherite_tool.json",
4338 ] {
4339 assert!(c.tags.get(tag).is_some_and(|ids| ids.contains_key("mymod:ore")), "{tag}: {:?}", c.tags);
4340 }
4341 let iron = format!(r#"{{ {cube}, block: {{ tool: "pickaxe", tool_level: "iron" }} }}"#);
4343 let c = compile(&project(&[("content/ore.yblock", &iron)]), "mymod", &yres::Refs::default()).unwrap();
4344 assert!(c.tags["data/minecraft/tags/block/needs_iron_tool.json"].contains_key("mymod:ore"));
4345 assert_eq!(c.tags.len(), 2);
4346 let bad = format!(r#"{{ {cube}, block: {{ tool: "hammer", tool_level: "mythril" }} }}"#);
4348 let e = compile(&project(&[("content/ore.yblock", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4349 assert!(e.contains("block.tool: `hammer`: one of pickaxe, axe, shovel, hoe — or a tag of the mod's own tools"), "{e}");
4350 assert!(e.contains("block.tool_level: `mythril`: one of wood, stone, iron, diamond, netherite — or a level of the tool ladder"), "{e}");
4351 let volts = r#"{ kind: "ladder", id: "voltage" }"#;
4352 let lv = r#"{ kind: "level", id: "lv", ladder: "@ladder:mymod:voltage" }"#;
4353 let loop_ = r#"{ kind: "level", id: "hv", ladder: "@ladder:mymod:voltage", above: "@level:mymod:nope" }"#;
4354 let wrong = format!(r#"{{ {cube}, block: {{ tool_level: "@level:mymod:lv", drops: {{ item: "@item:mymod:nope" }} }} }}"#);
4355 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();
4356 assert!(e.contains("level mymod:hv: above mymod:nope, which isn't a level of mymod:voltage"), "{e}");
4357 assert!(e.contains("block.tool_level: mymod:lv is a level of mymod:voltage, not of the tool ladder"), "{e}");
4358 assert!(e.contains("block.drops.item: no item mymod:nope in this mod"), "{e}");
4359 }
4360
4361 #[test]
4362 fn reports_errors_with_lines() {
4363 let root = project(&[(
4365 "content/bad.yblock",
4366 "{\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}",
4367 )]);
4368 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
4369 for want in [
4370 "content/bad.yblock:5: model.elements[0].to: 99 is out of range (-16..=32)",
4371 "content/bad.yblock:6: model.elements[0].rotation.axis: unknown `q` — did you mean `x`?",
4372 "content/bad.yblock:7: model.elements[0].faces.nort: unknown field `nort` — did you mean `north`?",
4373 "content/bad.yblock:10: bogus: unknown field `bogus`",
4374 ] {
4375 assert!(e.contains(want), "missing {want}\n{e}");
4376 }
4377 let _ = std::fs::remove_dir_all(root);
4378 let root = project(&[(
4380 "content/bad.yblock",
4381 "{\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}",
4382 )]);
4383 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
4384 for want in [
4385 "content/bad.yblock:6: model.elements[0].rotation.angle: 30 — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees",
4386 "content/bad.yblock:7: model.elements[0].faces.north.texture: `#1`: no such slot in textures",
4387 ] {
4388 assert!(e.contains(want), "missing {want}\n{e}");
4389 }
4390 let _ = std::fs::remove_dir_all(root);
4391 }
4392
4393 #[test]
4394 fn compiles_recipes() {
4395 let root = project(&[
4396 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
4397 (
4398 "content/recipes/ruby_block.yrecipe",
4399 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
4400 ),
4401 (
4402 "content/recipes/ruby.yrecipe",
4403 r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
4404 ),
4405 (
4406 "content/recipes/smelt_ruby.yrecipe",
4407 r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
4408 ),
4409 (
4410 "content/recipes/cut.yrecipe",
4411 r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
4412 ),
4413 ]);
4414 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4415 assert_eq!(c.recipes, 4);
4416 let src = c.source.clone().unwrap().all();
4417 for want in [
4418 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"#,
4419 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
4420 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
4421 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
4422 r##"group: Some("rubies".into())"##,
4423 ] {
4424 assert!(src.contains(want), "missing {want}\n{src}");
4425 }
4426 let _ = std::fs::remove_dir_all(root);
4427 }
4428
4429 #[test]
4430 fn recipe_mistakes() {
4431 let bad = |recipe: &str| {
4432 let root = project(&[("content/r.yrecipe", recipe)]);
4433 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
4434 let _ = std::fs::remove_dir_all(root);
4435 e
4436 };
4437 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
4438 assert!(e.contains("pattern uses 'B'"), "{e}");
4439 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
4440 assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
4441 let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
4442 assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
4443 let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
4444 assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
4445 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
4446 assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
4447 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
4448 assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
4449 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
4450 assert!(e.contains("unknown field `pattern`"), "{e}");
4451 let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
4452 assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
4453 }
4454
4455 #[test]
4456 fn block_inventory_names_a_screen() {
4457 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
4458 let root = project(&[
4459 ("content/bench.yblock", block),
4460 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
4461 ]);
4462 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4463 assert!(c.source.unwrap().all().contains(r#".inventory("mymod:bench")"#));
4464 let _ = std::fs::remove_dir_all(root);
4465
4466 let root = project(&[("content/bench.yblock", block)]);
4467 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
4468 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
4469 let _ = std::fs::remove_dir_all(root);
4470 }
4471
4472 #[test]
4473 fn compiles_a_book() {
4474 let book = r#"{
4475 "id": "guide",
4476 "name": "@Book.title",
4477 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
4478 "entries": [{
4479 "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
4480 "pages": [
4481 { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
4482 { "type": "spotlight", "item": "@item:mymod:ruby" },
4483 { "type": "entity", "entity": "@entity:minecraft:allay" }
4484 ]
4485 }]
4486 }"#;
4487 let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
4488 let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
4489 let mut refs = yres::Refs::default();
4490 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
4491 let out = compile(&root, "mymod", &refs).unwrap();
4492 assert_eq!(out.books, 1);
4493 let src = out.source.unwrap().all();
4494 assert!(src.contains(r#"registry.register_book_data("mymod:guide", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/books/mymod__guide.ydata")));"#), "{src}");
4495 assert_eq!(out.data_files[0].0, "books/mymod__guide.ydata");
4497 let data = yog_data::binary::decode(&out.data_files[0].1).unwrap();
4498 let text = yog_data::text::to_string(&data);
4499 assert!(text.contains("mymod.book.title") && text.contains("item.mymod.ruby"), "{text}");
4500 assert!(text.contains("not a ref") && text.contains("i18n"), "{text}");
4501 let read = yog_data::from_value::<yog_book::Book>(&data).unwrap().value;
4502 assert_eq!(read.entries[0].category, "mymod:basics");
4503 assert!(matches!(&read.entries[0].pages[1], yog_book::BookPage::Spotlight { item, .. } if item == "mymod:ruby"), "references become ids");
4504
4505 let bad = r#"{ "id": "x",
4507 "categories": [{ "id": "c", "name": "C" }],
4508 "entries": [{ "id": "e", "name": "E", "category": "c",
4509 "pages": [
4510 { "type": "spotlight", "item": "minecraft:stick" },
4511 { "type": "spotlight", "item": "@item:minecraft:grass" },
4512 { "type": "recipe", "recipe": "@recipe:mymod:nope" },
4513 { "type": "entity", "entity": "@entity:create:train" }
4514 ] }] }"#;
4515 let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4516 assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
4517 assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
4518 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}");
4519 assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
4520 assert!(e.contains("[external.create]"), "{e}");
4521
4522 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
4523 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
4524 assert!(e.contains("entries[0].pages[0].type: unknown page type `nope`"), "{e}");
4525 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
4526 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
4527 assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
4528 }
4529
4530 #[test]
4531 fn books_use_the_mods_own_page_types() {
4532 let ty = r#"<Component name="PatternPage" book_page="pattern">
4533 <Property name="name" type="book_text"/>
4534 <Property name="patterns" type="data" default="[]"/>
4535 <BookHeader text="{x:Bind name}"/>
4536 <Drawn drawer="mymod:pattern" h="64" data="{x:Bind patterns}"/>
4537 </Component>"#;
4538 let book = |page: &str| format!(r#"{{ id: "guide", name: "G", categories: [{{ id: "mymod:c", name: "C" }}],
4539 entries: [{{ id: "mymod:e", name: "E", category: "@category:mymod:c", pages: [{page}] }}] }}"#);
4540 let ok = book(r#"{ type: "mymod:pattern", name: "@item:minecraft:stick", patterns: [{ startdir: "EAST", signature: "qaq" }] }"#);
4541 let c = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &ok)]), "mymod", &yres::Refs::default()).unwrap();
4542 let b = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4543 assert_eq!(b.page_types.len(), 1, "the type goes with the book");
4544 let yog_book::BookPage::Custom(p) = &b.entries[0].pages[0] else { panic!("{:?}", b.entries[0].pages[0]) };
4545 assert_eq!(p.fields.get("name").and_then(|n| n.as_str()), Some("item.minecraft.stick"), "texts resolve as the book's do");
4546 assert!(b.i18n);
4547
4548 let unknown = book(r#"{ type: "mymod:pattern", name: "N", colour: 3 }"#);
4549 let e = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &unknown)]), "mymod", &yres::Refs::default()).unwrap_err();
4550 assert!(e.contains("mymod:pattern has no field `colour` (name, patterns)"), "{e}");
4551 let missing = book(r#"{ type: "mymod:nope" }"#);
4552 let e = compile(&project(&[("content/guide.ybook", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4553 assert!(e.contains("no page type mymod:nope"), "{e}");
4554 }
4555
4556 #[test]
4557 fn tools_and_armor_of_the_games_materials_or_the_mods() {
4558 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/iron_pickaxe" } }"#;
4559 let level = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
4560 let tool = r#"{ kind: "tool_material", id: "mythril", durability: 1800, speed: 10, attack_damage: 3, enchantability: 18,
4561 level: "@level:mymod:mythril", repair: "@tag:item:c:ingots" }"#;
4562 let armor = r#"{ kind: "armor_material", id: "mythril", durability: 30, defense: { head: 3, chest: 8, legs: 6, feet: 3 },
4563 enchantability: 18, equip_sound: "minecraft:item.armor.equip_diamond", repair: "@item:minecraft:diamond" }"#;
4564 let pick = format!(r#"{{ {model}, item: {{ tool: {{ kind: "pickaxe", material: "@tool_material:mymod:mythril" }} }} }}"#);
4565 let helm = format!(r#"{{ {model}, item: {{ armor: {{ slot: "head", material: "iron" }} }} }}"#);
4566 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())];
4567 let c = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap();
4568 let src = c.source.unwrap().all();
4569 assert!(src.contains(r#".tool("pickaxe", "mymod:mythril", 1.0, -2.8)"#), "the kind's attack: {src}");
4570 assert!(src.contains(r#".armor("head", "minecraft:iron")"#), "{src}");
4571 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}");
4572 assert!(src.contains(r#"registry.register_armor_material(::yog_api::ArmorMaterial { id: "mymod:mythril".into()"#) && src.contains(r#"texture: "mymod:mythril".into()"#), "{src}");
4573
4574 let bad = format!(r#"{{ {model}, item: {{ tool: {{ kind: "spoon", material: "bronze" }} }} }}"#);
4575 let e = compile(&project(&[("content/bad.yitem", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4576 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}");
4577 let missing = format!(r#"{{ {model}, item: {{ armor: {{ slot: "feet", material: "@armor_material:mymod:nope" }} }} }}"#);
4578 let e = compile(&project(&[("content/m.yitem", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4579 assert!(e.contains("no armor material mymod:nope"), "{e}");
4580 }
4581
4582 #[test]
4583 fn data_naming_renamed_content_gets_each_versions_name() {
4584 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4585 let block = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:short_grass" }} }} }}"#);
4586 let c = compile(&project(&[("content/tuft.yblock", &block)]), "mymod", &yres::Refs::default()).unwrap();
4587 let loot = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| String::from_utf8_lossy(b).to_string());
4588 assert!(loot("data/mymod/loot_table/blocks/tuft.json").is_some_and(|l| l.contains("minecraft:short_grass")), "the datapack's name");
4589 let old = loot("when/any/1.20.1/data/mymod/loot_table/blocks/tuft.json").expect("1.20.1's overlay");
4590 assert!(old.contains("minecraft:grass") && !old.contains("short_grass"), "{old}");
4591 let crafter = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:crafter" }} }} }}"#);
4592 let c = compile(&project(&[("content/c.yblock", &crafter)]), "mymod", &yres::Refs::default()).unwrap();
4593 assert!(c.warnings.iter().any(|w| w.contains("minecraft:crafter, which Minecraft 1.20.1 hasn't got")), "{:?}", c.warnings);
4594 }
4595
4596 #[test]
4597 fn ores_become_features_in_biomes_on_every_loader() {
4598 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4599 let block = format!(r#"{{ {model} }}"#);
4600 let ore = r#"{ kind: "ore", id: "mythril_ore", targets: [{ replaces: "stone", block: "@block:mymod:mythril_ore" },
4601 { replaces: "@block:minecraft:basalt", block: "@block:mymod:mythril_ore" }], size: 6, per_chunk: 4,
4602 height: { min: -48, max: 16, shape: "triangle" } }"#;
4603 let c = compile(&project(&[("content/mythril_ore.yblock", &block), ("content/mythril_ore.ydef", ore)]), "mymod", &yres::Refs::default()).unwrap();
4604 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}"));
4605 let cf = get("data/mymod/worldgen/configured_feature/mythril_ore.json");
4606 assert_eq!(cf["config"]["targets"][0]["target"]["tag"], "minecraft:stone_ore_replaceables");
4607 assert_eq!(cf["config"]["targets"][1]["target"]["block"], "minecraft:basalt");
4608 let pf = get("data/mymod/worldgen/placed_feature/mythril_ore.json");
4609 assert_eq!(pf["placement"][2]["height"]["type"], "minecraft:trapezoid");
4610 assert_eq!(get("when/neoforge/1.21/data/mymod/neoforge/biome_modifier/mythril_ore.json")["type"], "neoforge:add_features");
4611 assert_eq!(get("when/forge/any/data/mymod/forge/biome_modifier/mythril_ore.json")["biomes"], "#minecraft:is_overworld");
4612 let src = c.source.unwrap().all();
4613 assert!(src.contains(r#"registry.add_biome_feature("mymod:mythril_ore", "minecraft:is_overworld", "underground_ores");"#), "{src}");
4614 let bad = r#"{ kind: "ore", id: "x", targets: [{ replaces: "granite", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4615 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4616 assert!(e.contains("`granite`: one of stone, deepslate, netherrack, end_stone"), "{e}");
4617 let missing = r#"{ kind: "ore", id: "x", targets: [{ replaces: "stone", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4618 let e = compile(&project(&[("content/x.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4619 assert!(e.contains("no block mymod:nope"), "{e}");
4620 }
4621
4622 #[test]
4623 fn advancements_are_definitions_for_every_version() {
4624 let root = r#"{ kind: "advancement", id: "root", icon: "@item:minecraft:amethyst_shard", title: "Gems",
4625 background: "minecraft:textures/block/calcite.png", criteria: [{ has_item: "@tag:item:yog:gems/amethyst" }] }"#;
4626 let lore = r#"{ kind: "advancement", id: "lore/one", parent: "@advancement:mymod:root", icon: "@item:minecraft:paper",
4627 title: "@lang:advancement.mymod.lore.one", hidden: true, criteria: [{ granted: true }] }"#;
4628 let c = compile(&project(&[("content/root.ydef", root), ("content/lore.ydef", lore)]), "mymod", &yres::Refs::default()).unwrap();
4629 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}"));
4630 let new = get("data/mymod/advancement/root.json");
4631 assert_eq!(new["display"]["icon"]["id"], "minecraft:amethyst_shard");
4632 assert_eq!(new["criteria"]["c0"]["conditions"]["items"][0]["items"], "#yog:gems/amethyst");
4633 assert_eq!(new["display"]["title"]["translate"], "advancement.mymod.root");
4634 let old = get("when/any/1.20.1/data/mymod/advancement/root.json");
4635 assert_eq!(old["display"]["icon"]["item"], "minecraft:amethyst_shard");
4636 assert_eq!(old["criteria"]["c0"]["conditions"]["items"][0]["tag"], "yog:gems/amethyst");
4637 let one = get("data/mymod/advancement/lore/one.json");
4638 assert_eq!((one["parent"].as_str(), one["criteria"]["c0"]["trigger"].as_str()), (Some("mymod:root"), Some("minecraft:impossible")));
4639 assert_eq!(c.lang[&("mymod".to_string(), yres::NEUTRAL_LOCALE.to_string())]["advancement.mymod.root"], "Gems");
4640 let src = &c.source.as_ref().unwrap().all();
4642 assert!(src.contains("pub const LORE_ONE: &'static str = \"mymod:lore/one\";"), "{src}");
4643 let bad = r#"{ kind: "advancement", id: "x", icon: "@item:minecraft:paper", title: "X", criteria: [{ granted: true, used_item: "@item:minecraft:paper" }] }"#;
4644 assert!(compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err().contains("one of granted"));
4645 }
4646
4647 #[test]
4648 fn a_mod_has_loot_tables_of_its_own() {
4649 let table = r#"{ kind: "loot_table", id: "random_gem", add: [{ item: "@item:minecraft:diamond", count: [1, 3] }] }"#;
4650 let c = compile(&project(&[("content/random_gem.ydef", table)]), "mymod", &yres::Refs::default()).unwrap();
4651 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap());
4652 assert_eq!(get("data/mymod/loot_table/random_gem.json").expect("the table")["pools"], json!([]));
4653 let src = c.source.unwrap().all();
4654 assert!(src.contains(r#"registry.add_loot_modifier_data("mymod:random_gem""#), "{src}");
4655 let modifier = r#"{ kind: "loot_modifier", id: "more_gems", tables: ["@loot_table:minecraft:chests/simple_dungeon"], add: [{ item: "@item:minecraft:emerald", chance: 0.5 }] }"#;
4656 let c = compile(&project(&[("content/more_gems.ydef", modifier)]), "mymod", &yres::Refs::default()).unwrap();
4657 assert!(c.source.unwrap().all().contains(r#"registry.add_loot_modifier_data("mymod:more_gems""#));
4658 let missing = r#"{ kind: "loot_modifier", id: "m", tables: ["@loot_table:minecraft:chests/nope"], add: [] }"#;
4659 assert!(compile(&project(&[("content/m.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err().contains("chests/nope"));
4660 let bad = r#"{ kind: "loot_table", id: "minecraft:nope", add: [] }"#;
4661 assert!(compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err().contains("the mod's own"));
4662 }
4663
4664 #[test]
4665 fn a_block_says_how_its_item_stacks() {
4666 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4667 let block = format!(r#"{{ item: {{ stack_size: 16, fire_resistant: true, fuel_ticks: 300 }}, {model} }}"#);
4668 let plain = format!(r#"{{ {model} }}"#);
4669 let c = compile(&project(&[("content/log.yblock", &block), ("content/stone.yblock", &plain)]), "mymod", &yres::Refs::default()).unwrap();
4670 let src = c.source.unwrap().all();
4671 assert!(src.contains(r#"registry.register_item(::yog_api::ItemDef::new("mymod:log").max_stack(16).fire_resistant().fuel(300));"#), "{src}");
4672 assert!(!src.contains(r#"ItemDef::new("mymod:stone")"#), "no item form of its own: the host's default");
4673 }
4674
4675 #[test]
4676 fn blocks_and_items_join_their_tags() {
4677 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4678 let log = format!(r#"{{ tags: ["minecraft:logs", "edified_logs"], {model} }}"#);
4679 let staff = r#"{ tags: ["staves"], model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } } }"#;
4680 let c = compile(&project(&[("content/log.yblock", &log), ("content/staff.yitem", staff)]), "mymod", &yres::Refs::default()).unwrap();
4681 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"] {
4682 assert!(c.tags[t].contains_key("mymod:log"), "{t}");
4683 }
4684 assert!(c.tags["data/mymod/tags/item/staves.json"].contains_key("mymod:staff"));
4685 assert!(!c.tags.contains_key("data/mymod/tags/block/staves.json"), "an item has no block tag");
4686 }
4687
4688 #[test]
4689 fn trees_are_definitions() {
4690 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4691 let block = format!(r#"{{ {model} }}"#);
4692 let tree = r#"{ kind: "tree", id: "glass_tree", min_clipped_height: 6,
4693 trunk: { blocks: [{ block: "@block:mymod:log", weight: 8 }, { block: "@block:minecraft:oak_log" }], shape: "fancy", base_height: 5, random_a: 5, random_b: 3 },
4694 leaves: { blocks: [{ block: "@block:mymod:leaves" }], shape: "fancy", radius: 1, offset: 5, height: 5 } }"#;
4695 let c = compile(&project(&[("content/log.yblock", &block), ("content/leaves.yblock", &block), ("content/glass_tree.ydef", tree)]), "mymod", &yres::Refs::default()).unwrap();
4696 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}"));
4697 let f = &get("data/mymod/worldgen/configured_feature/glass_tree.json")["config"];
4698 assert_eq!(f["trunk_placer"]["type"], "minecraft:fancy_trunk_placer");
4699 assert_eq!(f["trunk_provider"]["entries"][0]["weight"], 8);
4700 assert_eq!(f["foliage_provider"]["state"]["Name"], "mymod:leaves");
4701 assert_eq!((f["foliage_placer"]["height"].as_u64(), f["minimum_size"]["min_clipped_height"].as_u64()), (Some(5), Some(6)));
4702 let bad = r#"{ kind: "tree", id: "t", trunk: { blocks: [{ block: "@block:mymod:nope" }], shape: "straight", base_height: 4 },
4703 leaves: { blocks: [{ block: "@block:minecraft:oak_leaves" }], shape: "blob", radius: 2 } }"#;
4704 let e = compile(&project(&[("content/t.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4705 assert!(e.contains("no block mymod:nope"), "{e}");
4706 }
4707
4708 #[test]
4709 fn what_a_version_or_loader_hasnt_got_keeps_a_recipe_out_of_it() {
4710 let recipe = |key: &str| format!(r#"{{ type: "shaped", pattern: ["D"], key: {{ D: {key} }}, result: "@item:minecraft:stick" }}"#);
4711 let check = |key: &str| {
4712 let c = compile(&project(&[("content/r.yrecipe", &recipe(key))]), "mymod", &yres::Refs::default());
4713 c.map(|c| c.warnings.iter().filter(|w| w.contains("left out")).cloned().collect::<Vec<_>>().join("\n"))
4714 };
4715 let w = check(r#""@tag:item:minecraft:music_discs""#).unwrap();
4716 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}");
4717 assert_eq!(check(r#""@tag:item:yog:music_discs""#).unwrap(), "");
4718 assert_eq!(check(r#"["@tag:item:c:iron_ingots", "@item:minecraft:iron_ingot"]"#).unwrap(), "", "an alternative that's everywhere");
4719 let w = check(r#""@tag:item:c:iron_ingots""#).unwrap();
4720 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}");
4721 let e = check(r#""@tag:item:c:nope""#).unwrap_err();
4722 assert!(e.contains("no item tag c:nope in Minecraft or its loaders"), "{e}");
4723 let e = check(r#""@tag:item:yog:nope""#).unwrap_err();
4724 assert!(e.contains("no item tag yog:nope among Yog's common tags"), "{e}");
4725 assert!(check(r#""@tag:item:othermod:anything""#).is_ok(), "another mod's tags aren't known here");
4726 let block = r#"{ tags: ["yog:planks", "yog:nope"], model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } } }"#;
4727 let e = compile(&project(&[("content/p.yblock", block)]), "mymod", &yres::Refs::default()).unwrap_err();
4728 assert!(e.contains("tags[1]: no block tag yog:nope") && !e.contains("yog:planks"), "{e}");
4729 }
4730
4731 #[test]
4732 fn tags_and_damage_types_are_definitions() {
4733 let tag = r#"{ kind: "tag", of: "entity", id: "sticky_teleporters", values: ["@entity:minecraft:pig", "@tag:entity:c:bosses"],
4734 optional: ["@entity:create:seat"] }"#;
4735 let logs = r#"{ kind: "tag", of: "block", id: "minecraft:logs", values: ["@block:minecraft:stone"] }"#;
4736 let dmg = r#"{ kind: "damage_type", id: "overcast", scaling: "never", tags: ["minecraft:bypasses_armor", "bypasses_effects"] }"#;
4737 let c = compile(&project(&[("content/a.ydef", tag), ("content/b.ydef", logs), ("content/c.ydef", dmg)]), "mymod", &yres::Refs::default()).unwrap();
4738 let sticky = &c.tags["data/mymod/tags/entity_type/sticky_teleporters.json"];
4739 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");
4740 assert!(c.tags["data/minecraft/tags/block/logs.json"].contains_key("minecraft:stone"));
4741 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}"));
4742 let d = get("data/mymod/damage_type/overcast.json");
4743 assert_eq!((d["message_id"].as_str(), d["scaling"].as_str()), (Some("mymod.overcast"), Some("never")));
4744 for t in ["data/minecraft/tags/damage_type/bypasses_armor.json", "data/minecraft/tags/damage_type/bypasses_effects.json"] {
4745 assert!(c.tags[t].contains_key("mymod:overcast"), "{t}");
4746 }
4747 let bad = r#"{ kind: "tag", of: "entity", id: "x", values: ["@entity:minecraft:nope", "@block:minecraft:stone"] }"#;
4748 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4749 assert!(e.contains("nope") && e.contains("`@block:minecraft:stone` in a tag of entity"), "{e}");
4750 }
4751
4752 #[test]
4753 fn a_dimension_and_its_terrain_go_to_the_runtime_as_data() {
4754 let dim = r#"{ kind: "dimension", id: "flatlands", dimension_type: { has_sky_light: true }, biome: "@biome:minecraft:desert",
4755 terrain: { seed: 3, base_height: 70, noise: [{ scale: 80, amplitude: 6 }], fill: "@block:minecraft:sandstone",
4756 layers: [{ block: "@block:minecraft:sand", depth: 3 }] } }"#;
4757 let c = compile(&project(&[("content/flatlands.ydef", dim)]), "mymod", &yres::Refs::default()).unwrap();
4758 let (path, data) = c.data_files.iter().find(|(p, _)| p.starts_with("dimensions/")).expect("the dimension's data");
4759 assert_eq!(path, "dimensions/mymod__flatlands.ydata");
4760 let v = yog_data::binary::decode(data).unwrap();
4761 assert_eq!(v.get("biome"), Some(&yog_data::Value::from("minecraft:desert")), "a plain id: the hosts read it");
4762 assert_eq!(v.get("generator_type"), Some(&yog_data::Value::from("mymod:flatlands")));
4763 let t = yog_data::read_value::<yog_worldgen::Terrain>(v.get("terrain").unwrap()).unwrap();
4764 assert_eq!((t.base_height, t.fill.as_str()), (70, "minecraft:sandstone"));
4765 let src = c.source.unwrap().all();
4766 assert!(src.contains(r#"registry.register_dimension_data("mymod:flatlands", include_bytes!("#), "{src}");
4767 let bad = r#"{ kind: "dimension", id: "x", terrain: { fill: "@block:minecraft:nope" } }"#;
4768 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4769 assert!(e.contains("no block minecraft:nope"), "{e}");
4770 }
4771
4772 #[test]
4773 fn content_for_other_mods_is_registered_only_with_them() {
4774 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } }"#;
4775 let item = format!(r#"{{ requires: ["pehkui", "create"], {model} }}"#);
4776 let recipe = r#"{ requires: ["pehkui"], type: "shapeless", ingredients: ["@item:minecraft:stick"], result: "@item:minecraft:stick" }"#;
4777 let c = compile(&project(&[("content/shrink.yitem", &item), ("content/r.yrecipe", recipe)]), "mymod", &yres::Refs::default()).unwrap();
4778 let src = c.source.unwrap().all();
4779 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") && ::yog_api::mod_loaded("create") { registry.register_item(Items::shrink()); }"#), "{src}");
4780 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") { registry.add_recipe("#), "{src}");
4781 }
4782
4783 #[test]
4784 fn books_lay_out_the_mods_own_recipe_kinds() {
4785 let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
4786 let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
4787 entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
4788 pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
4789 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true } } }"#;
4790 let layout = r#"<Component name="InfusionLayout" recipe_layout="infusion" width="96" height="24">
4791 <RecipeSlot slot="core" x="4" y="4" frame="true"/>
4792 <ItemSlot item="minecraft:beacon" x="40" y="4"/>
4793 <RecipeResult x="76" y="4" frame="true"/>
4794 </Component>"#;
4795 let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book), ("content/infusion.ydef", kind)];
4796 let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
4797 assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion"), "{e}");
4798 let c = compile(&project(&[files[0], files[1], files[2], ("src/ui/infusion.yui", layout)]), "mymod", &yres::Refs::default()).unwrap();
4799 let book = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4800 assert_eq!(book.layouts[0].kinds, ["mymod:infusion"], "the book carries the mod's layouts");
4801 let bad = r#"<Component recipe_layout="create:mixing infusion" width="1" height="1">
4803 <RecipeSlot slot="ring"/><ItemSlot item="minecraft:nope"/></Component>"#;
4804 let e = compile(&project(&[files[2], ("src/ui/bad.yui", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4805 assert!(e.contains("`create:mixing`: one of shaped"), "{e}");
4806 assert!(e.contains("mymod:infusion has no slot `ring` — core"), "{e}");
4807 assert!(e.contains("<ItemSlot item=\"minecraft:nope\">: no item minecraft:nope in Minecraft"), "{e}");
4808 }
4809
4810 #[test]
4811 fn compiles_a_recipe_of_the_mods_own_kind() {
4812 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true }, ring: { max: 2 } }, values: { time: "number" } }"#;
4813 let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
4814 values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
4815 let c = compile(&project(&[("content/infusion.ydef", kind), ("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
4816 let src = c.source.unwrap().all();
4817 assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
4818 assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
4819
4820 let bad = r##"{ type: "mymod:infusion", slots: { ring: ["@item:minecraft:coal", "@item:minecraft:coal", "@item:minecraft:coal"], nope: ["@item:minecraft:coal"] },
4822 values: { time: "soon", heat: 3 }, result: "@item:minecraft:emerald" }"##;
4823 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4824 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)"] {
4825 assert!(e.contains(want), "{want}: {e}");
4826 }
4827 let undeclared = r#"{ "type": "mymod:infusion", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4828 let e = compile(&project(&[("content/u.yrecipe", undeclared)]), "mymod", &yres::Refs::default()).unwrap_err();
4829 assert!(e.contains("type `mymod:infusion`: no such kind — declare it: content/infusion.ydef"), "{e}");
4830 let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
4831 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4832 assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
4833 let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4835 let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
4836 assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
4837 }
4838
4839 #[test]
4840 fn compiles_an_item_with_resource_refs() {
4841 let root = project(&[(
4842 "content/items/ruby.yitem",
4843 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
4844 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
4845 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
4846 )]);
4847 let mut refs = yres::Refs::default();
4848 refs.texts.insert(
4849 "Items.ruby".into(),
4850 ("item.mymod.ruby".into(), "Ruby".into()),
4851 );
4852 refs.texts.insert(
4853 "Strings.ruby_tip".into(),
4854 ("mymod.ruby_tip".into(), "Shiny".into()),
4855 );
4856 refs.images
4857 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
4858 let c = compile(&root, "mymod", &refs).unwrap();
4859 assert_eq!(
4860 output(&c, "assets/mymod/models/item/ruby.json"),
4861 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
4862 );
4863 let src = c.source.clone().unwrap().all();
4864 assert!(src.contains(".max_stack(16)"), "{src}");
4865 assert!(
4866 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
4867 "{src}"
4868 );
4869 let mut outputs = vec![(
4871 "assets/mymod/lang/ru_ru.json".to_string(),
4872 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
4873 .as_bytes()
4874 .to_vec(),
4875 )];
4876 merge_outputs(&root, &mut outputs, c).unwrap();
4877 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
4878 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
4879 let _ = std::fs::remove_dir_all(root);
4880 }
4881
4882 #[test]
4883 fn compiles_a_potion_item_model_without_registering_a_second_item() {
4884 let root = project(&[(
4885 "content/items/potion.yitem",
4886 r#"{ "item": { "potion": { "effects": [] } },
4887 "model": { "parent": "minecraft:item/potion",
4888 "textures": { "layer0": "minecraft:item/potion_overlay" } } }"#,
4889 )]);
4890 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
4891 assert_eq!(
4892 output(&c, "assets/mymod/models/item/potion.json"),
4893 json!({
4894 "parent": "minecraft:item/potion",
4895 "textures": { "layer0": "minecraft:item/potion_overlay" }
4896 })
4897 );
4898 assert!(
4899 !c.outputs.iter().any(|(path, _)| path == "assets/mymod/items/potion.json"),
4900 "a potion registry entry must not create a second item definition"
4901 );
4902 let _ = std::fs::remove_dir_all(root);
4903 }
4904
4905 #[test]
4906 fn namespace_needed_for_odd_mod_ids() {
4907 let root = project(&[(
4908 "content/x.yitem",
4909 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
4910 )]);
4911 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
4912 assert!(e.contains("give the namespace"), "{e}");
4913 let _ = std::fs::remove_dir_all(root);
4914 }
4915
4916 #[test]
4917 fn body_files_compile_and_register() {
4918 let model = r#"{ "texture": "misc/wings.png", "bones": [
4919 { "name": "wing", "pivot": [5, 0, 0], "cubes": [{ "origin": [-10, 0, 0], "size": [10, 20, 2], "uv": [22, 0] }] } ] }"#;
4920 let anim = r#"{ "spread": { "length": 0.5, "bones": { "wing": { "rotation": { "0": [0, 0, 0], "0.5": [0, 0, -90] } } } } }"#;
4921 let out = compile(&project(&[("content/wings.ymodel", model), ("content/wings.yanim", anim)]), "mymod", &yres::Refs::default()).unwrap();
4922 assert!(out.data_files.iter().any(|(p, _)| p == "body/models/mymod__wings.ydata"));
4923 let src = out.source.unwrap().all();
4924 assert!(src.contains("registry.register_body_model(\"mymod:wings\""), "{src}");
4925 assert!(src.contains("registry.register_body_anims(\"mymod:wings\""), "{src}");
4926 assert!(src.contains("pub const WINGS: &'static str = \"mymod:wings\";"), "{src}");
4927 let bad = r#"{ "texture": "x.png", "bones": [{ "name": "a", "parent": "b" }] }"#;
4928 let e = compile(&project(&[("content/bad.ymodel", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4929 assert!(e.contains("must come before it"), "{e}");
4930 }
4931
4932 #[test]
4933 fn font_files_compile_validate_and_register() {
4934 let font = r#"{ units_per_em: 100, ascent: 80, descent: -20,
4935 glyphs: [{ name: "a", advance: 100, ops: [] }],
4936 chars: [{ char: 97, glyph: 0 }] }"#;
4937 let out = compile(&project(&[("content/type/a.yfont", font)]), "mymod", &yres::Refs::default()).unwrap();
4938 let (_, bytes) = out.data_files.iter().find(|(p, _)| p == "fonts/mymod__type__a.yfont").unwrap();
4939 let built = yog_font::Font::from_bytes(bytes).unwrap();
4940 assert_eq!(built.glyph_of('a'), Some(0));
4941 let src = out.source.unwrap().part("register").to_string();
4942 assert!(src.contains("registry.register_font_data(\"mymod:type/a\""), "{src}");
4943 assert!(src.contains("/generated/fonts/mymod__type__a.yfont"), "{src}");
4944
4945 let invalid = r#"{ units_per_em: 100, ascent: 80, descent: -20,
4946 glyphs: [{ advance: 100, ops: [] }],
4947 chars: [{ char: 97, glyph: 4 }] }"#;
4948 let error = compile(&project(&[("content/bad.yfont", invalid)]), "mymod", &yres::Refs::default()).unwrap_err();
4949 assert!(error.contains("chars[0].glyph 4 does not name a glyph"), "{error}");
4950 }
4951
4952 #[test]
4953 fn entities_compile_register_and_name_their_models() {
4954 let model = r#"{ "texture": "entity/wisp.png", "bones": [{ "name": "body", "cubes": [{ "origin": [-2, 0, -2], "size": [4, 4, 4] }] }] }"#;
4955 let wisp = r#"{ "name": "Wisp", "kind": "mob", "width": 0.5, "height": 0.5, "model": "wisp", "goals": ["float", "wander"],
4956 "attributes": { "max_health": 6, "movement_speed": 0.3 } }"#;
4957 let out = compile(&project(&[("content/wisp.ymodel", model), ("content/wisp.yentity", wisp)]), "mymod", &yres::Refs::default()).unwrap();
4958 let src = out.source.as_ref().unwrap();
4960 assert!(src.part("register").contains("registry.register_entity_data(\"mymod:wisp\""), "{src:?}");
4961 assert!(src.part("entities").contains("pub const WISP: &'static str = \"mymod:wisp\";"), "{src:?}");
4962 assert!(src.root.contains("\"/generated/content/entities.g.rs\""), "{}", src.root);
4963 assert!(src.root.lines().all(|l| l.starts_with("//") || l.starts_with("include!(")), "{}", src.root);
4964 assert!(out.data_files.iter().any(|(p, _)| p == "entities/mymod__wisp.ydata"));
4965 let (_, data) = out.data_files.iter().find(|(p, _)| p == "entities/mymod__wisp.ydata").unwrap();
4966 let def: yog_registry::EntityDef = yog_data::read_bytes(data).unwrap();
4967 assert_eq!((def.kind, def.model.as_deref()), (yog_registry::EntityKind::Mob, Some("mymod:wisp")));
4968 let missing = r#"{ "kind": "object", "width": 1, "height": 1, "model": "nope" }"#;
4969 let e = compile(&project(&[("content/thing.yentity", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4970 assert!(e.contains("model `mymod:nope`: no such file"), "{e}");
4971 }
4972
4973}