Skip to main content

yog_registry/
lib.rs

1//! Content registration — declare custom items and blocks from Rust.
2//!
3//! Definitions are collected at registration time and handed to the host, which
4//! registers real `Item`/`Block` objects before the game's registries freeze,
5//! puts them in per-namespace creative tabs, and applies their properties.
6//! (Textures, models and recipes are assets/data shipped in the `.yog` and
7//! served to the game.)
8//!
9//! ```
10//! # use yog_registry::{ItemDef, BlockDef, FoodDef};
11//! ItemDef::new("mymod:ruby").name("Ruby").tooltip("A shiny gem.").max_stack(16);
12//! ItemDef::new("mymod:pie").food(FoodDef::new(4, 0.3));
13//! BlockDef::new("mymod:lamp").light_level(15).sound("stone");
14//! ```
15
16#[cfg(feature = "serde")]
17use serde::{Deserialize, Serialize};
18
19pub mod effects;
20pub mod loot;
21
22/// What a `.yog` registers when the game starts — content the game can't
23/// add or change while it runs (blocks, items, entity types, sound groups,
24/// materials, dimensions): a line each, `<kind> <id> <hash>`, the hash of
25/// what can't change. A hot reload compares the new `.yog`'s with what's
26/// registered ([`registration_changes`]).
27pub const REGISTRATIONS_FILE: &str = "registrations.txt";
28
29/// How `new` (a [`REGISTRATIONS_FILE`]) differs from `registered`: what it
30/// adds, removes and changes — `<kind> <id>` each.
31pub fn registration_changes(registered: &str, new: &str) -> (Vec<String>, Vec<String>, Vec<String>) {
32    fn read(text: &str) -> std::collections::BTreeMap<String, String> {
33        text.lines()
34            .filter_map(|l| {
35                let (key, hash) = l.trim().rsplit_once(' ')?;
36                Some((key.to_string(), hash.to_string()))
37            })
38            .collect()
39    }
40    let (old, new) = (read(registered), read(new));
41    let added = new.keys().filter(|k| !old.contains_key(*k)).cloned().collect();
42    let removed = old.keys().filter(|k| !new.contains_key(*k)).cloned().collect();
43    let changed = new.iter().filter(|(k, h)| old.get(*k).is_some_and(|o| o != *h)).map(|(k, _)| k.clone()).collect();
44    (added, removed, changed)
45}
46pub use effects::{AttributeDef, Brew, EffectCategory, EffectDef, EffectModifier, ModifierOperation, PotionDef, PotionEffect};
47pub use loot::{Loot, LootAdd, LootModifier, LootScale, LootStack};
48
49// ── Items ────────────────────────────────────────────────────────────────────
50
51/// Nutritional properties for a food item.
52#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
53#[derive(yog_data::Schema, Debug, Clone)]
54pub struct FoodDef {
55    /// Hunger points restored (1 unit = half a drumstick).
56    pub nutrition: u32,
57    /// Saturation modifier applied after eating.
58    pub saturation: f32,
59    /// If `true`, edible even when the hunger bar is full.
60    pub can_always_eat: bool,
61}
62
63impl FoodDef {
64    pub fn new(nutrition: u32, saturation: f32) -> Self {
65        Self {
66            nutrition,
67            saturation,
68            can_always_eat: false,
69        }
70    }
71
72    pub fn can_always_eat(mut self) -> Self {
73        self.can_always_eat = true;
74        self
75    }
76}
77
78/// A custom item to register, identified by `namespace:path`.
79#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
80#[derive(yog_data::Schema, Debug, Clone)]
81pub struct ItemDef {
82    pub id: String,
83    pub max_stack: u8,
84    pub name: Option<String>,
85    pub tooltip: Option<String>,
86    /// Durability. `0` = non-damageable. If set, `max_stack` is forced to 1.
87    pub max_damage: u32,
88    /// Immune to fire and lava damage (like netherite items).
89    pub fire_resistant: bool,
90    /// Furnace fuel burn time in ticks (`0` = not fuel; 200 = one coal equivalent).
91    pub fuel_ticks: u32,
92    /// Nutritional properties; `None` = not food.
93    pub food: Option<FoodDef>,
94    /// A tool or a weapon of a material.
95    pub tool: Option<ToolItem>,
96    /// A piece of armor of a material.
97    pub armor: Option<ArmorPiece>,
98    /// Worn in this slot without being armor: `head`, `chest`, `legs`, `feet`.
99    pub wearable: Option<String>,
100    /// Used on a block's side, it hangs there as an entity of a hanging type.
101    pub hangs: Option<Hangs>,
102}
103
104/// What an item hangs on a wall ([`ItemDef::hangs`]).
105#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
106#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
107pub struct Hangs {
108    /// A hanging entity type ([`EntityKind::Hanging`]).
109    pub entity: String,
110    /// Whole blocks of wall, wide and high; `None`: the type's size.
111    pub size: Option<[u32; 2]>,
112}
113
114impl ItemDef {
115    pub fn new(id: impl Into<String>) -> Self {
116        Self {
117            id: id.into(),
118            max_stack: 64,
119            name: None,
120            tooltip: None,
121            max_damage: 0,
122            fire_resistant: false,
123            fuel_ticks: 0,
124            food: None,
125            tool: None,
126            armor: None,
127            wearable: None,
128            hangs: None,
129        }
130    }
131
132    /// A tool (`pickaxe`, `axe`, `shovel`, `hoe`, a mod's mineable tag
133    /// `mymod:mineable/hammer`) or a `sword`, of `material` (a
134    /// [`ToolMaterial`]: the game's `iron`… or a mod's).
135    pub fn tool(mut self, kind: impl Into<String>, material: impl Into<String>, attack_damage: f32, attack_speed: f32) -> Self {
136        self.tool = Some(ToolItem { kind: kind.into(), material: material.into(), attack_damage, attack_speed });
137        self
138    }
139
140    /// A piece of armor for `slot` (`head`, `chest`, `legs`, `feet`) of
141    /// `material` (an [`ArmorMaterial`]: the game's `diamond`… or a mod's).
142    pub fn armor(mut self, slot: impl Into<String>, material: impl Into<String>) -> Self {
143        self.armor = Some(ArmorPiece { slot: slot.into(), material: material.into() });
144        self
145    }
146
147    /// Worn in `slot` (`head`, `chest`, `legs`, `feet`) without being armor.
148    pub fn wearable(mut self, slot: impl Into<String>) -> Self {
149        self.wearable = Some(slot.into());
150        self
151    }
152
153    /// Used on a block's side, it hangs an `entity` (a hanging type) there,
154    /// `size` blocks wide and high (`None`: the type's own); the entity
155    /// keeps one of this item and drops it when it breaks.
156    pub fn hangs(mut self, entity: impl Into<String>, size: Option<[u32; 2]>) -> Self {
157        self.hangs = Some(Hangs { entity: entity.into(), size });
158        self
159    }
160
161    /// Maximum stack size (default 64). Ignored when `max_damage` is set.
162    pub fn max_stack(mut self, n: u8) -> Self {
163        self.max_stack = n;
164        self
165    }
166
167    /// Display name shown in-game.
168    pub fn name(mut self, name: impl Into<String>) -> Self {
169        self.name = Some(name.into());
170        self
171    }
172
173    /// A tooltip line shown on hover.
174    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
175        self.tooltip = Some(tooltip.into());
176        self
177    }
178
179    /// Make this a damageable item (tool/weapon/armour). Forces stack size to 1.
180    pub fn max_damage(mut self, durability: u32) -> Self {
181        self.max_damage = durability;
182        self
183    }
184
185    /// Make this item fire-resistant (won't burn in fire or lava).
186    pub fn fire_resistant(mut self) -> Self {
187        self.fire_resistant = true;
188        self
189    }
190
191    /// Register as furnace fuel burning for `ticks` (200 ticks = 1 item smelted).
192    pub fn fuel(mut self, ticks: u32) -> Self {
193        self.fuel_ticks = ticks;
194        self
195    }
196
197    /// Make this item edible with the given nutritional properties.
198    pub fn food(mut self, food: FoodDef) -> Self {
199        self.food = Some(food);
200        self
201    }
202}
203
204// ── Recipes ──────────────────────────────────────────────────────────────────
205
206/// Namespace for recipe ids given without one.
207const VANILLA_NS: &str = "minecraft";
208
209/// Split `namespace:name`, falling back to `default_ns` for a bare name.
210fn split_id<'a>(id: &'a str, default_ns: &'a str) -> (&'a str, &'a str) {
211    id.split_once(':').unwrap_or((default_ns, id))
212}
213
214
215/// `s` as a JSON string.
216fn json_str(s: &str) -> String {
217    let mut out = String::with_capacity(s.len() + 2);
218    out.push('"');
219    for c in s.chars() {
220        match c {
221            '"' => out.push_str("\\\""),
222            '\\' => out.push_str("\\\\"),
223            '\n' => out.push_str("\\n"),
224            '\r' => out.push_str("\\r"),
225            '\t' => out.push_str("\\t"),
226            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
227            c => out.push(c),
228        }
229    }
230    out.push('"');
231    out
232}
233
234
235/// How a cooking recipe cooks.
236#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum Cooking {
239    Smelting,
240    Blasting,
241    Smoking,
242    Campfire,
243}
244
245impl Cooking {
246    /// Its `type` in Yog's recipe format.
247    pub fn word(self) -> &'static str {
248        match self {
249            Cooking::Smelting => "smelting",
250            Cooking::Blasting => "blasting",
251            Cooking::Smoking => "smoking",
252            Cooking::Campfire => "campfire_cooking",
253        }
254    }
255}
256
257/// What a recipe is made of besides its result.
258#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
259#[derive(Debug, Clone, PartialEq)]
260pub enum RecipeKind {
261    /// A crafting grid: rows of key characters (space = empty).
262    Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
263    /// Crafting, in any arrangement.
264    Shapeless { ingredients: Vec<Vec<String>> },
265    Cooking { method: Cooking, ingredient: Vec<String>, experience: f32, cook_time: u32 },
266    Stonecutting { ingredient: Vec<String> },
267    /// A smithing table: `base` upgraded with `addition`, `template` used up.
268    Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
269}
270
271/// A recipe, in Yog's own format — what a `.yrecipe` says. The host writes
272/// the recipe JSON of its Minecraft version from it.
273///
274/// An ingredient is an item id (`minecraft:stick`), an item tag (`#c:dusts`)
275/// or — the `_any` builders — any of several.
276///
277/// ```
278/// # use yog_registry::{Cooking, Recipe};
279/// Recipe::shaped("yog:ruby_sword", "yog:ruby_sword", 1)
280///     .row(" R ").row(" R ").row(" S ")
281///     .key('R', "yog:ruby").key('S', "minecraft:stick");
282/// Recipe::cooking("yog:ruby", Cooking::Blasting, "#c:ruby_ores", "yog:ruby", 1).experience(0.7);
283/// ```
284#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
285#[derive(Debug, Clone, PartialEq)]
286pub struct Recipe {
287    pub id: String,
288    pub kind: RecipeKind,
289    pub result: String,
290    pub count: u32,
291    /// Recipes of a group share a slot in the recipe book.
292    pub group: Option<String>,
293    /// The recipe book's tab (`building`, `equipment`, `misc`, …).
294    pub category: Option<String>,
295}
296
297impl Recipe {
298    fn of(id: impl Into<String>, kind: RecipeKind, result: impl Into<String>, count: u32) -> Self {
299        Recipe { id: id.into(), kind, result: result.into(), count, group: None, category: None }
300    }
301
302    pub fn shaped(id: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
303        Self::of(id, RecipeKind::Shaped { pattern: Vec::new(), key: Vec::new() }, result, count)
304    }
305
306    pub fn shapeless(id: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
307        Self::of(id, RecipeKind::Shapeless { ingredients: Vec::new() }, result, count)
308    }
309
310    /// Cooking `ingredient` (100 % of a vanilla furnace: 0.1 experience,
311    /// 200 ticks — see [`Self::experience`], [`Self::cook_time`]).
312    pub fn cooking(
313        id: impl Into<String>,
314        method: Cooking,
315        ingredient: impl Into<String>,
316        result: impl Into<String>,
317        count: u32,
318    ) -> Self {
319        let cook_time = match method {
320            Cooking::Smelting => 200,
321            Cooking::Blasting | Cooking::Smoking => 100,
322            Cooking::Campfire => 600,
323        };
324        let kind = RecipeKind::Cooking { method, ingredient: vec![ingredient.into()], experience: 0.1, cook_time };
325        Self::of(id, kind, result, count)
326    }
327
328    pub fn stonecutting(id: impl Into<String>, ingredient: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
329        Self::of(id, RecipeKind::Stonecutting { ingredient: vec![ingredient.into()] }, result, count)
330    }
331
332    pub fn smithing(
333        id: impl Into<String>,
334        template: impl Into<String>,
335        base: impl Into<String>,
336        addition: impl Into<String>,
337        result: impl Into<String>,
338    ) -> Self {
339        let kind = RecipeKind::Smithing {
340            template: vec![template.into()],
341            base: vec![base.into()],
342            addition: vec![addition.into()],
343        };
344        Self::of(id, kind, result, 1)
345    }
346
347    /// A row of a shaped recipe's pattern.
348    pub fn row(mut self, pattern: impl Into<String>) -> Self {
349        if let RecipeKind::Shaped { pattern: p, .. } = &mut self.kind {
350            p.push(pattern.into());
351        }
352        self
353    }
354
355    /// What `symbol` stands for in a shaped recipe.
356    pub fn key(self, symbol: char, item: impl Into<String>) -> Self {
357        self.key_any(symbol, [item.into()])
358    }
359
360    /// `symbol` stands for any of `items`.
361    pub fn key_any(mut self, symbol: char, items: impl IntoIterator<Item = impl Into<String>>) -> Self {
362        if let RecipeKind::Shaped { key, .. } = &mut self.kind {
363            key.push((symbol, items.into_iter().map(Into::into).collect()));
364        }
365        self
366    }
367
368    /// One more ingredient of a shapeless recipe.
369    pub fn ingredient(self, item: impl Into<String>) -> Self {
370        self.ingredient_any([item.into()])
371    }
372
373    /// One more ingredient of a shapeless recipe: any of `items`.
374    pub fn ingredient_any(mut self, items: impl IntoIterator<Item = impl Into<String>>) -> Self {
375        if let RecipeKind::Shapeless { ingredients } = &mut self.kind {
376            ingredients.push(items.into_iter().map(Into::into).collect());
377        }
378        self
379    }
380
381    /// Experience a cooking recipe gives.
382    pub fn experience(mut self, xp: f32) -> Self {
383        if let RecipeKind::Cooking { experience, .. } = &mut self.kind {
384            *experience = xp;
385        }
386        self
387    }
388
389    /// A cooking recipe's time in ticks.
390    pub fn cook_time(mut self, ticks: u32) -> Self {
391        if let RecipeKind::Cooking { cook_time, .. } = &mut self.kind {
392            *cook_time = ticks;
393        }
394        self
395    }
396
397    pub fn group(mut self, group: impl Into<String>) -> Self {
398        self.group = Some(group.into());
399        self
400    }
401
402    pub fn category(mut self, category: impl Into<String>) -> Self {
403        self.category = Some(category.into());
404        self
405    }
406
407    /// The recipe in Yog's own format (the `.yrecipe` shape) — what
408    /// `Registry::add_recipe` hands the runtime.
409    pub fn to_data(&self) -> yog_data::Value {
410        use yog_data::{Map, Value};
411        let ing = |items: &[String]| match items {
412            [one] => Value::from(one.as_str()),
413            many => Value::List(many.iter().map(|i| Value::from(i.as_str())).collect()),
414        };
415        let mut m = Map::with_capacity(8);
416        let ty = |m: &mut Map, t: &str| m.insert("type", Value::from(t));
417        match &self.kind {
418            RecipeKind::Shaped { pattern, key } => {
419                ty(&mut m, "shaped");
420                m.insert("pattern", Value::List(pattern.iter().map(|r| Value::from(r.as_str())).collect()));
421                let mut keys = Map::with_capacity(key.len());
422                for (c, items) in key {
423                    keys.insert(c.to_string(), ing(items));
424                }
425                m.insert("key", Value::Map(keys));
426            }
427            RecipeKind::Shapeless { ingredients } => {
428                ty(&mut m, "shapeless");
429                m.insert("ingredients", Value::List(ingredients.iter().map(|i| ing(i)).collect()));
430            }
431            RecipeKind::Cooking { method, ingredient, experience, cook_time } => {
432                ty(&mut m, method.word());
433                m.insert("ingredient", ing(ingredient));
434                // As written (`0.7`, not f32's 0.699999988…).
435                m.insert("experience", Value::Float(experience.to_string().parse().unwrap_or(0.0)));
436                m.insert("cook_time", Value::Int(*cook_time as i64));
437            }
438            RecipeKind::Stonecutting { ingredient } => {
439                ty(&mut m, "stonecutting");
440                m.insert("ingredient", ing(ingredient));
441            }
442            RecipeKind::Smithing { template, base, addition } => {
443                ty(&mut m, "smithing");
444                m.insert("template", ing(template));
445                m.insert("base", ing(base));
446                m.insert("addition", ing(addition));
447            }
448        }
449        m.insert("result", Value::from(self.result.as_str()));
450        if self.count != 1 {
451            m.insert("count", Value::Int(self.count as i64));
452        }
453        if let Some(g) = &self.group {
454            m.insert("group", Value::from(g.as_str()));
455        }
456        if let Some(c) = &self.category {
457            m.insert("category", Value::from(c.as_str()));
458        }
459        Value::Map(m)
460    }
461
462    /// Split `namespace:name` from the recipe id.
463    pub fn ns_name(&self) -> (&str, &str) {
464        split_id(&self.id, VANILLA_NS)
465    }
466}
467
468// ── BookRecipe (like Patchouli's shapeless_book_recipe) ──────────────────────
469
470
471
472// ── ItemModifier ─────────────────────────────────────────────────────────────
473
474
475
476// ── StartupGrant ─────────────────────────────────────────────────────────────
477
478/// Grant items/books to every player once when they first join.
479/// This is the Yog-side replacement for `grant_patchi_book.json`.
480#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
481#[derive(yog_data::Schema, Debug, Clone)]
482pub struct StartupGrant {
483    pub id: String,
484    pub items: Vec<String>,
485    pub book: Option<String>,
486    pub command: Option<String>,
487}
488
489impl StartupGrant {
490    pub fn new(id: impl Into<String>) -> Self {
491        Self {
492            id: id.into(),
493            items: Vec::new(),
494            book: None,
495            command: None,
496        }
497    }
498
499    pub fn item(mut self, item_id: impl Into<String>) -> Self {
500        self.items.push(item_id.into());
501        self
502    }
503
504    pub fn book(mut self, book: impl Into<String>) -> Self {
505        self.book = Some(book.into());
506        self
507    }
508
509    pub fn command(mut self, cmd: impl Into<String>) -> Self {
510        self.command = Some(cmd.into());
511        self
512    }
513
514    pub fn to_json(&self) -> String {
515        let mut entries: Vec<String> = self.items.iter().map(|i| format!("{{\"item\":{}}}", json_str(i))).collect();
516        if let Some(book) = &self.book {
517            // The book's id inside SNBT inside JSON: escaped for both.
518            let tag = format!("{{yog_book:\"{}\"}}", book.replace('\\', "\\\\").replace('"', "\\\""));
519            entries.push(format!(
520                "{{\"type\":\"item\",\"name\":\"minecraft:written_book\",\"functions\":[{{\"function\":\"set_nbt\",\"tag\":{}}}]}}",
521                json_str(&tag)
522            ));
523        }
524        if let Some(cmd) = &self.command {
525            entries.push(format!("{{\"type\":\"minecraft:command\",\"command\":{}}}", json_str(cmd)));
526        }
527        format!("{{\"type\":\"yog:startup_grant\",\"entries\":[{}],\"id\":{}}}", entries.join(","), json_str(&self.id))
528    }
529}
530
531
532
533// ── Tools and armor ─────────────────────────────────────────────────────────
534
535/// An item as a tool or weapon.
536#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
537#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
538pub struct ToolItem {
539    /// `pickaxe`, `axe`, `shovel`, `hoe`, `sword`, or the mineable tag of
540    /// a mod's kind of tool (`mymod:mineable/hammer`).
541    pub kind: String,
542    /// Its [`ToolMaterial`]'s id (`minecraft:iron`, `mymod:mythril`).
543    pub material: String,
544    /// Attack damage on top of the material's (an iron pickaxe: 1).
545    pub attack_damage: f32,
546    /// Attack speed change (a pickaxe: -2.8, a sword: -2.4).
547    pub attack_speed: f32,
548}
549
550/// An item as a piece of armor.
551#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
552#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
553pub struct ArmorPiece {
554    /// `head`, `chest`, `legs` or `feet`.
555    pub slot: String,
556    /// Its [`ArmorMaterial`]'s id.
557    pub material: String,
558}
559
560/// What tools are made of. The game's are built in
561/// ([`ToolMaterial::built_ins`]); a mod declares its own in a `.ydef`
562/// (`kind: "tool_material"`) or registers one
563/// (`Registry::register_tool_material`).
564#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
565#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
566pub struct ToolMaterial {
567    pub id: String,
568    /// Uses before it breaks.
569    pub durability: u32,
570    /// How fast it mines (wood 2 … netherite 9, gold 12).
571    pub speed: f32,
572    /// Attack damage it adds (wood 0 … netherite 4).
573    pub attack_damage: f32,
574    pub enchantability: u32,
575    /// Its level on the tool ladder (`minecraft:iron`, `mymod:mythril`).
576    pub level: String,
577    /// What repairs it: an item id or `#tag`.
578    pub repair: String,
579    /// Minecraft 1.20's level number of `level` (wood 0 … netherite 4).
580    #[data(default)]
581    pub level_index: u32,
582    /// Minecraft 1.21's tag of the blocks a tool of it can't mine for
583    /// drops (`minecraft:incorrect_for_iron_tool`).
584    #[data(default)]
585    pub incorrect: String,
586}
587
588/// Defense points per armor piece.
589#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
590#[derive(yog_data::Schema, Debug, Clone, Copy, PartialEq)]
591pub struct ArmorDefense {
592    pub head: u32,
593    pub chest: u32,
594    pub legs: u32,
595    pub feet: u32,
596}
597
598/// What armor is made of. The game's are built in
599/// ([`ArmorMaterial::built_ins`]); a mod declares its own in a `.ydef`
600/// (`kind: "armor_material"`) or registers one.
601#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
602#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
603pub struct ArmorMaterial {
604    pub id: String,
605    /// Multiplies each piece's base durability (helmet 11, chestplate 16,
606    /// leggings 15, boots 13).
607    pub durability: u32,
608    pub defense: ArmorDefense,
609    pub enchantability: u32,
610    /// The sound event of putting it on.
611    pub equip_sound: String,
612    #[data(default)]
613    pub toughness: f32,
614    #[data(default)]
615    pub knockback_resistance: f32,
616    /// What repairs it: an item id or `#tag`.
617    pub repair: String,
618    /// Its look on a player: `<ns>:<name>` is
619    /// `<ns>:textures/models/armor/<name>_layer_1.png` (and `_layer_2`).
620    pub texture: String,
621}
622
623static BUILT_IN_TOOL_MATERIALS: std::sync::LazyLock<Vec<ToolMaterial>> = std::sync::LazyLock::new(|| {
624    yog_data::from_text::<Vec<ToolMaterial>>(include_str!("../assets/tool_materials.ydata"))
625        .unwrap_or_else(|e| panic!("yog-registry's built-in tool materials: {e}"))
626        .value
627});
628
629static BUILT_IN_ARMOR_MATERIALS: std::sync::LazyLock<Vec<ArmorMaterial>> = std::sync::LazyLock::new(|| {
630    yog_data::from_text::<Vec<ArmorMaterial>>(include_str!("../assets/armor_materials.ydata"))
631        .unwrap_or_else(|e| panic!("yog-registry's built-in armor materials: {e}"))
632        .value
633});
634
635/// One of `list` by short name or id (`iron` = `minecraft:iron`).
636fn built_in<'a, T>(list: &'a [T], id: impl Fn(&T) -> &str, name: &str) -> Option<&'a T> {
637    let name = name.strip_prefix("minecraft:").unwrap_or(name);
638    list.iter().find(|x| id(x).strip_prefix("minecraft:") == Some(name))
639}
640
641impl ToolMaterial {
642    /// The game's materials: wood, stone, iron, diamond, gold, netherite.
643    pub fn built_ins() -> &'static [ToolMaterial] {
644        &BUILT_IN_TOOL_MATERIALS
645    }
646
647    /// One of the game's, by short name or id.
648    pub fn built_in(name: &str) -> Option<&'static ToolMaterial> {
649        built_in(Self::built_ins(), |m| &m.id, name)
650    }
651
652    /// `id` with `like`'s level (one of the game's: `iron`), durability,
653    /// speed, damage, enchantability and repair — change what differs.
654    pub fn like(id: impl Into<String>, like: &str) -> Option<Self> {
655        let mut m = Self::built_in(like)?.clone();
656        m.id = id.into();
657        Some(m)
658    }
659}
660
661impl ArmorMaterial {
662    /// The game's materials: leather, chainmail, iron, gold, diamond, turtle, netherite.
663    pub fn built_ins() -> &'static [ArmorMaterial] {
664        &BUILT_IN_ARMOR_MATERIALS
665    }
666
667    /// One of the game's, by short name or id.
668    pub fn built_in(name: &str) -> Option<&'static ArmorMaterial> {
669        built_in(Self::built_ins(), |m| &m.id, name)
670    }
671}
672
673// ── World generation ────────────────────────────────────────────────────────
674
675/// A placed feature (`mymod:mythril_ore`, the mod's datapack's) added to
676/// biomes at a generation step — what an ore `.ydef` makes. Forge and
677/// NeoForge read the biome modifiers `yog build` writes; Fabric's host adds
678/// these through its biome API.
679#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
680#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
681pub struct BiomeFeature {
682    /// The placed feature's id.
683    pub feature: String,
684    /// The tag of biomes it goes in (`minecraft:is_overworld`).
685    pub biomes: String,
686    /// The generation step (`underground_ores`, `vegetal_decoration`, …).
687    pub step: String,
688}
689
690// ── Sound groups ─────────────────────────────────────────────────────────────
691
692/// What a block sounds like: the sound events it plays when broken,
693/// stepped on, placed, hit while mining and fallen on. The game's groups
694/// are built in ([`SoundGroup::built_in`]); a mod registers its own
695/// (`Registry::register_sound_group`, or a `.ydef` of kind `sound_group`)
696/// and names it in `BlockDef::sound`.
697#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
698#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
699pub struct SoundGroup {
700    pub id: String,
701    /// Sound event ids (`minecraft:block.stone.break`, `mymod:crystal.break`).
702    #[data(rename = "break")]
703    pub break_sound: String,
704    pub step: String,
705    pub place: String,
706    pub hit: String,
707    pub fall: String,
708    #[data(default = "one")]
709    #[data(range = "0..=10")]
710    pub volume: f32,
711    #[data(default = "one")]
712    #[data(range = "0..=10")]
713    pub pitch: f32,
714}
715
716fn one() -> f32 {
717    1.0
718}
719
720static BUILT_IN_SOUND_GROUPS: std::sync::LazyLock<Vec<SoundGroup>> = std::sync::LazyLock::new(|| {
721    yog_data::from_text::<Vec<SoundGroup>>(include_str!("../assets/sound_groups.ydata"))
722        .unwrap_or_else(|e| panic!("yog-registry's built-in sound groups: {e}"))
723        .value
724});
725
726impl SoundGroup {
727    /// `mymod:crystal`, its sounds `mymod:crystal.break`, `.step`, `.place`,
728    /// `.hit`, `.fall`; volume and pitch 1.
729    pub fn new(id: impl Into<String>) -> Self {
730        let id = id.into();
731        let ev = |s: &str| format!("{id}.{s}");
732        Self { break_sound: ev("break"), step: ev("step"), place: ev("place"), hit: ev("hit"), fall: ev("fall"), volume: 1.0, pitch: 1.0, id }
733    }
734
735    /// The five sound events: break, step, place, hit, fall.
736    pub fn sounds(mut self, break_sound: &str, step: &str, place: &str, hit: &str, fall: &str) -> Self {
737        [self.break_sound, self.step, self.place, self.hit, self.fall] = [break_sound, step, place, hit, fall].map(String::from);
738        self
739    }
740
741    pub fn volume(mut self, volume: f32) -> Self {
742        self.volume = volume;
743        self
744    }
745
746    pub fn pitch(mut self, pitch: f32) -> Self {
747        self.pitch = pitch;
748        self
749    }
750
751    /// The game's groups (`minecraft:stone`, `minecraft:amethyst`…, from
752    /// Minecraft's `SoundType`), as data.
753    pub fn built_ins() -> &'static [SoundGroup] {
754        &BUILT_IN_SOUND_GROUPS
755    }
756
757    /// One of the game's groups, by short name or id (`stone` = `minecraft:stone`).
758    pub fn built_in(name: &str) -> Option<&'static SoundGroup> {
759        let name = name.strip_prefix("minecraft:").unwrap_or(name);
760        Self::built_ins().iter().find(|g| g.id.strip_prefix("minecraft:") == Some(name))
761    }
762}
763
764// ── Blocks ───────────────────────────────────────────────────────────────────
765
766/// A custom block to register; it also gets a matching block-item.
767#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
768#[derive(yog_data::Schema, Debug, Clone)]
769pub struct BlockDef {
770    pub id: String,
771    pub hardness: f32,
772    pub resistance: f32,
773    pub name: Option<String>,
774    /// Optional collision/outline box in pixel units (0–16): `[x1,y1,z1,x2,y2,z2]`.
775    /// `None` = full cube.
776    pub shape: Option<[f32; 6]>,
777    /// Light emitted by this block (0 = none, 15 = max, like a torch).
778    pub light_level: u8,
779    /// Its [`SoundGroup`]: one of the game's by name (`"stone"`,
780    /// `"amethyst"`, `"copper"` — [`SoundGroup::built_in`]) or a mod's
781    /// (`"mymod:crystal"`). `None` = stone (Minecraft default).
782    pub sound: Option<String>,
783    /// If `true`, the correct tool (from the block's tags) is required for drops.
784    pub requires_tool: bool,
785    /// If `true`, entities pass through this block (like flowers or torches).
786    pub no_collision: bool,
787    /// Friction coefficient. `0.0` = default (0.6). Ice = 0.989.
788    pub slipperiness: f32,
789    /// If `true`, this block dynamically grows arms toward neighbors it's
790    /// compatible with (fence/pipe-style): the Java host tracks N/S/E/W/U/D
791    /// boolean blockstate properties, recomputed on placement and on
792    /// neighbor change, and grows the collision/outline shape from the
793    /// `shape` core box (or a default post) out to each connected side.
794    pub connects: bool,
795    /// Connection compatibility tags (configured in code via
796    /// `.connect_groups(&[...])` — see that method's docs for how this
797    /// drives which blocks link up). Independent of `connects`: a block can
798    /// carry tags purely as a connection *target* (e.g. an ALU accepting a
799    /// Digital Cable) without dynamically growing its own shape.
800    pub connect_groups: Vec<String>,
801    /// Id of a `YogInventoryDef` this block is backed by — `None` = plain
802    /// block (default). See `yog_inventory`'s DESIGN.md.
803    pub inventory_id: Option<String>,
804    /// Gives off redstone power — how much, per position, the mod sets with
805    /// `Server::set_redstone_output`.
806    pub redstone_source: bool,
807    /// How it turns when placed (a `facing` property): `""` (it doesn't),
808    /// `"horizontal"` or `"all"`.
809    pub facing: String,
810    /// `"toward"`: its front toward the placer; `"away"`: the way they look.
811    pub placement: String,
812    /// It hangs on a floor, wall or ceiling, as a button (a `face`
813    /// property and a horizontal `facing`).
814    pub attach: bool,
815    /// It can stand in water (a `waterlogged` property).
816    pub waterlogged: bool,
817    /// Properties of its own.
818    pub properties: Vec<BlockProperty>,
819    /// A yes/no property the block keeps on while it gets redstone power.
820    pub powered: Option<String>,
821    /// A yes/no property a comparator reads 15 from while it's on.
822    pub comparator: Option<String>,
823    /// Hoppers and droppers can put items into it (`on_item_insert`).
824    pub accepts_items: bool,
825    /// It doesn't hide the faces behind it or stop light (as glass, a door).
826    #[data(default)]
827    pub no_occlusion: bool,
828    /// One of the game's kinds of block it is (`stairs`, `slab`, `door`,
829    /// `trapdoor`, `button`, `pressure_plate`, `fence`, `fence_gate`): the
830    /// host makes it of the game's class, with that class's states; `""`: a
831    /// block of Yog's own.
832    #[data(default)]
833    pub kind: String,
834    /// The block a kind is made from (stairs act as it).
835    #[data(default)]
836    pub base: Option<String>,
837    /// A kind's set — a registered block set by name (`oak`, `iron`…; a
838    /// fence gate's wood type); `None`: `oak`.
839    #[data(default)]
840    pub set: Option<String>,
841    /// How long a button stays pressed, ticks.
842    #[data(default = "thirty")]
843    pub press_ticks: u32,
844    /// Only living things set off a button or a pressure plate (not items
845    /// or arrows).
846    #[data(default)]
847    pub mobs_only: bool,
848}
849
850fn thirty() -> u32 {
851    30
852}
853
854/// A property of a block's own: its name and values (the first the
855/// default; `false`, `true` is a yes/no).
856#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
857#[derive(yog_data::Schema, Debug, Clone, PartialEq, Eq)]
858pub struct BlockProperty {
859    pub name: String,
860    pub values: Vec<String>,
861}
862
863impl BlockDef {
864    pub fn new(id: impl Into<String>) -> Self {
865        Self {
866            id: id.into(),
867            hardness: 1.5,
868            resistance: 6.0,
869            name: None,
870            shape: None,
871            light_level: 0,
872            sound: None,
873            requires_tool: false,
874            no_collision: false,
875            slipperiness: 0.0,
876            connects: false,
877            connect_groups: Vec::new(),
878            inventory_id: None,
879            redstone_source: false,
880            facing: String::new(),
881            placement: String::new(),
882            attach: false,
883            waterlogged: false,
884            properties: Vec::new(),
885            powered: None,
886            comparator: None,
887            accepts_items: false,
888            no_occlusion: false,
889            kind: String::new(),
890            base: None,
891            set: None,
892            press_ticks: thirty(),
893            mobs_only: false,
894        }
895    }
896
897    /// Mining hardness and blast resistance (defaults 1.5 / 6.0).
898    pub fn strength(mut self, hardness: f32, resistance: f32) -> Self {
899        self.hardness = hardness;
900        self.resistance = resistance;
901        self
902    }
903
904    /// Display name shown in-game.
905    pub fn name(mut self, name: impl Into<String>) -> Self {
906        self.name = Some(name.into());
907        self
908    }
909
910    /// Custom hitbox/outline in pixel units (0–16).
911    pub fn shape(mut self, x1: f32, y1: f32, z1: f32, x2: f32, y2: f32, z2: f32) -> Self {
912        self.shape = Some([x1, y1, z1, x2, y2, z2]);
913        self
914    }
915
916    /// Emitted light level (0–15).
917    pub fn light_level(mut self, level: u8) -> Self {
918        self.light_level = level.min(15);
919        self
920    }
921
922    /// Sound group: one of the game's (`"stone"`, `"amethyst"`…) or a
923    /// mod's [`SoundGroup`] (`"mymod:crystal"`).
924    pub fn sound(mut self, group: impl Into<String>) -> Self {
925        self.sound = Some(group.into());
926        self
927    }
928
929    /// Correct tool required for loot drops (equivalent to `requiresTool()`).
930    pub fn requires_tool(mut self) -> Self {
931        self.requires_tool = true;
932        self
933    }
934
935    /// No physical collision — entities pass through (like flowers).
936    pub fn no_collision(mut self) -> Self {
937        self.no_collision = true;
938        self
939    }
940
941    /// Friction (default 0.6). Set to 0.989 for ice-like slipperiness.
942    pub fn slipperiness(mut self, value: f32) -> Self {
943        self.slipperiness = value;
944        self
945    }
946
947    /// Dynamically grow arms toward compatible neighbors — fence/pipe-style.
948    /// "Compatible" means the neighbor also has a `connect_groups` tag in
949    /// common (set here, or via `connect_groups` alone on a static target
950    /// block). Call `.connect_groups(&[...])` too, or this connects to
951    /// nothing. See the `connects` field doc.
952    pub fn connects_to_neighbors(mut self) -> Self {
953        self.connects = true;
954        self
955    }
956
957    /// Connection compatibility tags — two blocks link up (for
958    /// `connects_to_neighbors` arm growth, and as valid connection targets
959    /// generally) when their tag sets share at least one entry. Example:
960    /// `analog_cable` and `redstone_port` both carry `"analog"`;
961    /// `digital_cable` carries `"digital"`; an ALU block carries both, so it
962    /// accepts either cable while a Redstone Port only accepts the analog one.
963    pub fn connect_groups(mut self, groups: &[&str]) -> Self {
964        self.connect_groups = groups
965            .iter()
966            .map(std::string::ToString::to_string)
967            .collect();
968        self
969    }
970
971    /// Back this block with a real Container/Menu inventory screen, whose
972    /// shape/layout was registered separately via `Registry::register_inventory`
973    /// — see `yog_inventory::InventoryDef`. `id` is that def's id.
974    /// A redstone power source: what each placed block gives off is set
975    /// with `Server::set_redstone_output` (0 until then).
976    pub fn redstone_source(mut self) -> Self {
977        self.redstone_source = true;
978        self
979    }
980
981    /// Turns when placed: `"horizontal"` or `"all"` ways, its front (the
982    /// model's north side) `"toward"` the placer or `"away"` (the way they look).
983    pub fn facing(mut self, ways: &str, placement: &str) -> Self {
984        self.facing = ways.to_string();
985        self.placement = placement.to_string();
986        self
987    }
988
989    /// Hangs on a floor, wall or ceiling, as a button.
990    pub fn attach(mut self) -> Self {
991        self.attach = true;
992        self
993    }
994
995    /// Can stand in water.
996    pub fn waterlogged(mut self) -> Self {
997        self.waterlogged = true;
998        self
999    }
1000
1001    /// A property of its own: `values`, the first the default.
1002    pub fn property(mut self, name: &str, values: &[&str]) -> Self {
1003        self.properties.push(BlockProperty { name: name.into(), values: values.iter().map(|v| v.to_string()).collect() });
1004        self
1005    }
1006
1007    /// Keeps yes/no property `name` on while it gets redstone power.
1008    pub fn powered(mut self, name: &str) -> Self {
1009        self.powered = Some(name.into());
1010        self
1011    }
1012
1013    /// A comparator reads 15 from it while yes/no property `name` is on.
1014    pub fn comparator(mut self, name: &str) -> Self {
1015        self.comparator = Some(name.into());
1016        self
1017    }
1018
1019    /// Hoppers and droppers can put items into it (`on_item_insert`).
1020    pub fn accepts_items(mut self) -> Self {
1021        self.accepts_items = true;
1022        self
1023    }
1024
1025    pub fn inventory(mut self, id: impl Into<String>) -> Self {
1026        self.inventory_id = Some(id.into());
1027        self
1028    }
1029
1030    /// Doesn't hide the faces behind it or stop light (as glass).
1031    pub fn no_occlusion(mut self) -> Self {
1032        self.no_occlusion = true;
1033        self
1034    }
1035
1036    /// One of the game's kinds of block (`"stairs"`, `"door"`…).
1037    pub fn kind(mut self, kind: &str) -> Self {
1038        self.kind = kind.into();
1039        self
1040    }
1041
1042    /// The block a kind is made from.
1043    pub fn base(mut self, id: impl Into<String>) -> Self {
1044        self.base = Some(id.into());
1045        self
1046    }
1047
1048    /// A kind's block set (`"oak"`, `"dark_oak"`, `"iron"`…).
1049    pub fn set(mut self, name: &str) -> Self {
1050        self.set = Some(name.into());
1051        self
1052    }
1053
1054    /// How long a button stays pressed, ticks (30 by default).
1055    pub fn press_ticks(mut self, ticks: u32) -> Self {
1056        self.press_ticks = ticks;
1057        self
1058    }
1059
1060    /// Only living things set off a button or a pressure plate.
1061    pub fn mobs_only(mut self) -> Self {
1062        self.mobs_only = true;
1063        self
1064    }
1065}
1066
1067// ── Entities ─────────────────────────────────────────────────────────────────
1068
1069/// What kind of entity a type is.
1070#[derive(yog_data::Schema, Debug, Clone, Copy, PartialEq, Eq, Default)]
1071pub enum EntityKind {
1072    /// Not alive: a thing in the world (a scroll on a wall, a vehicle, a
1073    /// display) — no health, no AI.
1074    #[default]
1075    Object,
1076    /// Alive: health, attributes, hurt and death, but no AI of its own.
1077    Living,
1078    /// A creature with AI: `goals`, and walking about.
1079    Mob,
1080    /// Hangs on a block's side, as a painting: `width`×`height` whole
1081    /// blocks of wall (the item it's placed from may say another size —
1082    /// [`ItemDef::hangs`]); it stays while the blocks behind hold it, and
1083    /// breaking it, or losing its wall, drops the item it was placed from.
1084    Hanging,
1085}
1086
1087/// An entity type to register (`.yentity`). Its look is a body model
1088/// (`model`, `.ymodel`) with clips by state; what it does beyond `goals` is
1089/// the mod's code (entity events, a tick of its own).
1090#[derive(yog_data::Schema, Debug, Clone)]
1091pub struct EntityDef {
1092    pub id: String,
1093    /// Its name (a text or a `@Set.key` resource); default: the id's lang key.
1094    pub name: Option<String>,
1095    #[data(default)]
1096    pub kind: EntityKind,
1097    /// Its spawn group (`MobCategory`): `misc`, `monster`, `creature`,
1098    /// `ambient`, `water_creature`, `water_ambient`, `underground_water_creature`.
1099    #[data(default = "misc")]
1100    pub category: String,
1101    /// Hitbox, blocks.
1102    pub width: f32,
1103    pub height: f32,
1104    /// Eye height, blocks; default 85% of the height.
1105    pub eye_height: Option<f32>,
1106    /// Lava and fire don't hurt it.
1107    #[data(default)]
1108    pub fire_immune: bool,
1109    /// It doesn't fall.
1110    #[data(default)]
1111    pub no_gravity: bool,
1112    /// Others push it (living things by default).
1113    pub pushable: Option<bool>,
1114    /// Others stand on and bump into it as into a block (a boat).
1115    #[data(default)]
1116    pub solid: bool,
1117    /// How far players see it, chunks.
1118    #[data(default = "eight")]
1119    pub tracking_range: u32,
1120    /// How often its movement is sent, ticks.
1121    #[data(default = "three")]
1122    pub update_interval: u32,
1123    /// Living and mob attributes by id: `max_health`, `movement_speed`,
1124    /// `attack_damage`, `follow_range`, `armor`, `knockback_resistance`… (the
1125    /// game's, `minecraft:` optional, or a mod's).
1126    #[data(default)]
1127    pub attributes: yog_data::Ordered<f64>,
1128    /// Its body model (`.ymodel` id); none: the mod draws it (`on_body`).
1129    pub model: Option<String>,
1130    /// Its clips (`.yanim` id).
1131    pub animations: Option<String>,
1132    /// Clip by state: `idle`, `walk`, `attack`, `hurt`, `death`, or the mod's
1133    /// own states (entity data `yog:state`).
1134    #[data(default)]
1135    pub clips: yog_data::Ordered<String>,
1136    /// Spawn egg colors `[base, spots]` (`0xRRGGBB`); none: no egg.
1137    pub spawn_egg: Option<[u32; 2]>,
1138    /// Mobs' AI goals, in priority order — the game's by name: `float`,
1139    /// `panic`, `melee_attack`, `wander`, `look_at_player`,
1140    /// `look_around`, `target_player`, `avoid_player`.
1141    #[data(default)]
1142    pub goals: Vec<String>,
1143    /// The mod handles each tick of every entity of this type
1144    /// (`on_entity_tick`).
1145    #[data(default)]
1146    pub ticks: bool,
1147    /// A hanging thing's sound events when it's placed and when it breaks;
1148    /// default: a painting's.
1149    pub place_sound: Option<String>,
1150    pub break_sound: Option<String>,
1151}
1152
1153fn eight() -> u32 {
1154    8
1155}
1156
1157fn three() -> u32 {
1158    3
1159}
1160
1161fn misc() -> String {
1162    "misc".into()
1163}
1164
1165impl EntityDef {
1166    pub fn new(id: impl Into<String>, kind: EntityKind, width: f32, height: f32) -> Self {
1167        Self {
1168            id: id.into(),
1169            name: None,
1170            kind,
1171            category: misc(),
1172            width,
1173            height,
1174            eye_height: None,
1175            fire_immune: false,
1176            no_gravity: false,
1177            pushable: None,
1178            solid: false,
1179            tracking_range: eight(),
1180            update_interval: three(),
1181            attributes: yog_data::Ordered::default(),
1182            model: None,
1183            animations: None,
1184            clips: yog_data::Ordered::default(),
1185            spawn_egg: None,
1186            goals: Vec::new(),
1187            ticks: false,
1188            place_sound: None,
1189            break_sound: None,
1190        }
1191    }
1192}
1193
1194#[cfg(test)]
1195mod registration_tests {
1196    #[test]
1197    fn a_hot_reload_sees_what_registration_changed() {
1198        let old = "block mymod:a 11\nblock mymod:b 22\nitem mymod:c 33\n";
1199        let new = "block mymod:a 11\nblock mymod:b 99\nitem mymod:d 44\n";
1200        let (added, removed, changed) = super::registration_changes(old, new);
1201        assert_eq!(added, ["item mymod:d"]);
1202        assert_eq!(removed, ["item mymod:c"]);
1203        assert_eq!(changed, ["block mymod:b"]);
1204        assert_eq!(super::registration_changes(old, old), (vec![], vec![], vec![]));
1205    }
1206}
1207
1208#[cfg(test)]
1209mod material_tests {
1210    use super::*;
1211
1212    #[test]
1213    fn the_games_materials_are_built_in() {
1214        assert_eq!(ToolMaterial::built_ins().len(), 6);
1215        let iron = ToolMaterial::built_in("iron").unwrap();
1216        assert_eq!((iron.durability, iron.level_index, iron.incorrect.as_str()), (250, 2, "minecraft:incorrect_for_iron_tool"));
1217        assert_eq!(ArmorMaterial::built_in("minecraft:netherite").unwrap().knockback_resistance, 0.1);
1218        assert_eq!(ArmorMaterial::built_in("diamond").unwrap().defense.chest, 8);
1219        let mythril = ToolMaterial::like("mymod:mythril", "diamond").unwrap();
1220        assert_eq!((mythril.id.as_str(), mythril.durability), ("mymod:mythril", 1561));
1221    }
1222}
1223
1224#[cfg(test)]
1225mod sound_tests {
1226    use super::*;
1227
1228    #[test]
1229    fn the_games_sound_groups_are_built_in() {
1230        assert!(SoundGroup::built_ins().len() > 100);
1231        let stone = SoundGroup::built_in("stone").unwrap();
1232        assert_eq!((stone.break_sound.as_str(), stone.volume), ("minecraft:block.stone.break", 1.0));
1233        assert_eq!(SoundGroup::built_in("minecraft:metal").unwrap().pitch, 1.5);
1234        assert!(SoundGroup::built_in("mymod:crystal").is_none());
1235    }
1236
1237    #[test]
1238    fn a_mods_group_crosses_as_yog_data() {
1239        use yog_data::Schema;
1240        let g = SoundGroup::new("mymod:crystal").pitch(1.2);
1241        assert_eq!(g.fall, "mymod:crystal.fall");
1242        let back = yog_data::from_value::<SoundGroup>(&g.write()).unwrap().value;
1243        assert_eq!(back, g);
1244        assert_eq!(g.write().get("break").and_then(|v| v.as_str()), Some("mymod:crystal.break"));
1245    }
1246}
1247
1248#[cfg(test)]
1249impl Recipe {
1250    fn to_json(&self) -> String {
1251        yog_data::json::to_json(&self.to_data()).to_string()
1252    }
1253}
1254
1255#[cfg(test)]
1256mod recipe_tests {
1257    use super::*;
1258
1259    #[test]
1260    fn writes_the_yog_format() {
1261        let js = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
1262        let shaped = Recipe::shaped("m:a", "m:b", 2).row("D ").key('D', "#c:dusts").key_any('E', ["m:x", "m:y"]).group("g");
1263        assert_eq!(
1264            js(&shaped.to_json()),
1265            js(r##"{"type":"shaped","pattern":["D "],"key":{"D":"#c:dusts","E":["m:x","m:y"]},"result":"m:b","count":2,"group":"g"}"##)
1266        );
1267        assert_eq!(shaped.to_data().as_map().unwrap().keys().next(), Some("type"), "the type first");
1268        let blast = Recipe::cooking("m:a", Cooking::Blasting, "#c:ores", "m:b", 1).experience(0.7);
1269        assert_eq!(js(&blast.to_json()), js(r##"{"type":"blasting","ingredient":"#c:ores","experience":0.7,"cook_time":100,"result":"m:b"}"##));
1270        let smith = Recipe::smithing("m:a", "m:t", "m:base", "m:add", "m:out");
1271        assert_eq!(js(&smith.to_json())["template"], "m:t");
1272    }
1273}
1274
1275#[cfg(test)]
1276mod json_tests {
1277    use super::*;
1278
1279    /// Every `to_json` makes valid JSON, whatever the strings hold.
1280    #[test]
1281    fn json_survives_any_string() {
1282        let odd = "m:a\"b\\c\nd\te\u{1}";
1283        let parse = |j: String| serde_json::from_str::<serde_json::Value>(&j).unwrap_or_else(|e| panic!("{e}: {j}"));
1284        for r in [
1285            Recipe::shaped(odd, odd, 1).row("A").key('A', odd).group(odd).category(odd),
1286            Recipe::shapeless(odd, odd, 3).ingredient(odd).ingredient_any([odd, "m:x"]),
1287            Recipe::cooking(odd, Cooking::Smoking, odd, odd, 1).cook_time(5),
1288            Recipe::stonecutting(odd, odd, odd, 2),
1289            Recipe::smithing(odd, odd, odd, odd, odd),
1290        ] {
1291            let v = parse(r.to_json());
1292            assert_eq!(v["result"], odd, "the string comes back as it was");
1293        }
1294        let g = StartupGrant::new(odd).item(odd).book("mymod:\"guide\"").command("say \"hi\" \\o/");
1295        let v = parse(g.to_json());
1296        assert_eq!(v["id"], odd);
1297        assert_eq!(v["entries"][2]["command"], "say \"hi\" \\o/");
1298    }
1299
1300    #[test]
1301    fn recipes_and_grants_name_their_parts() {
1302        let r = Recipe::cooking("mymod:a", Cooking::Campfire, "minecraft:beef", "minecraft:cooked_beef", 1);
1303        let v: serde_json::Value = serde_json::from_str(&r.to_json()).unwrap();
1304        assert_eq!(v["type"], "campfire_cooking");
1305        assert_eq!(v["cook_time"], 600, "the method's default time");
1306        assert_eq!(r.ns_name(), ("mymod", "a"));
1307        assert_eq!(Recipe::shaped("plain", "x", 1).ns_name().1, "plain");
1308        let g = StartupGrant::new("mymod:starter");
1309        assert!(g.to_json().contains(r#""entries":[]"#));
1310    }
1311}