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