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