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
19// ── Items ────────────────────────────────────────────────────────────────────
20
21/// Nutritional properties for a food item.
22#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23#[derive(yog_data::Schema, Debug, Clone)]
24pub struct FoodDef {
25    /// Hunger points restored (1 unit = half a drumstick).
26    pub nutrition: u32,
27    /// Saturation modifier applied after eating.
28    pub saturation: f32,
29    /// If `true`, edible even when the hunger bar is full.
30    pub can_always_eat: bool,
31}
32
33impl FoodDef {
34    pub fn new(nutrition: u32, saturation: f32) -> Self {
35        Self {
36            nutrition,
37            saturation,
38            can_always_eat: false,
39        }
40    }
41
42    pub fn can_always_eat(mut self) -> Self {
43        self.can_always_eat = true;
44        self
45    }
46}
47
48/// A custom item to register, identified by `namespace:path`.
49#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
50#[derive(yog_data::Schema, Debug, Clone)]
51pub struct ItemDef {
52    pub id: String,
53    pub max_stack: u8,
54    pub name: Option<String>,
55    pub tooltip: Option<String>,
56    /// Durability. `0` = non-damageable. If set, `max_stack` is forced to 1.
57    pub max_damage: u32,
58    /// Immune to fire and lava damage (like netherite items).
59    pub fire_resistant: bool,
60    /// Furnace fuel burn time in ticks (`0` = not fuel; 200 = one coal equivalent).
61    pub fuel_ticks: u32,
62    /// Nutritional properties; `None` = not food.
63    pub food: Option<FoodDef>,
64    /// A tool or a weapon of a material.
65    pub tool: Option<ToolItem>,
66    /// A piece of armor of a material.
67    pub armor: Option<ArmorPiece>,
68    /// Worn in this slot without being armor: `head`, `chest`, `legs`, `feet`.
69    pub wearable: Option<String>,
70}
71
72impl ItemDef {
73    pub fn new(id: impl Into<String>) -> Self {
74        Self {
75            id: id.into(),
76            max_stack: 64,
77            name: None,
78            tooltip: None,
79            max_damage: 0,
80            fire_resistant: false,
81            fuel_ticks: 0,
82            food: None,
83            tool: None,
84            armor: None,
85            wearable: None,
86        }
87    }
88
89    /// A tool (`pickaxe`, `axe`, `shovel`, `hoe`, a mod's mineable tag
90    /// `mymod:mineable/hammer`) or a `sword`, of `material` (a
91    /// [`ToolMaterial`]: the game's `iron`… or a mod's).
92    pub fn tool(mut self, kind: impl Into<String>, material: impl Into<String>, attack_damage: f32, attack_speed: f32) -> Self {
93        self.tool = Some(ToolItem { kind: kind.into(), material: material.into(), attack_damage, attack_speed });
94        self
95    }
96
97    /// A piece of armor for `slot` (`head`, `chest`, `legs`, `feet`) of
98    /// `material` (an [`ArmorMaterial`]: the game's `diamond`… or a mod's).
99    pub fn armor(mut self, slot: impl Into<String>, material: impl Into<String>) -> Self {
100        self.armor = Some(ArmorPiece { slot: slot.into(), material: material.into() });
101        self
102    }
103
104    /// Worn in `slot` (`head`, `chest`, `legs`, `feet`) without being armor.
105    pub fn wearable(mut self, slot: impl Into<String>) -> Self {
106        self.wearable = Some(slot.into());
107        self
108    }
109
110    /// Maximum stack size (default 64). Ignored when `max_damage` is set.
111    pub fn max_stack(mut self, n: u8) -> Self {
112        self.max_stack = n;
113        self
114    }
115
116    /// Display name shown in-game.
117    pub fn name(mut self, name: impl Into<String>) -> Self {
118        self.name = Some(name.into());
119        self
120    }
121
122    /// A tooltip line shown on hover.
123    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
124        self.tooltip = Some(tooltip.into());
125        self
126    }
127
128    /// Make this a damageable item (tool/weapon/armour). Forces stack size to 1.
129    pub fn max_damage(mut self, durability: u32) -> Self {
130        self.max_damage = durability;
131        self
132    }
133
134    /// Make this item fire-resistant (won't burn in fire or lava).
135    pub fn fire_resistant(mut self) -> Self {
136        self.fire_resistant = true;
137        self
138    }
139
140    /// Register as furnace fuel burning for `ticks` (200 ticks = 1 item smelted).
141    pub fn fuel(mut self, ticks: u32) -> Self {
142        self.fuel_ticks = ticks;
143        self
144    }
145
146    /// Make this item edible with the given nutritional properties.
147    pub fn food(mut self, food: FoodDef) -> Self {
148        self.food = Some(food);
149        self
150    }
151}
152
153// ── Recipes ──────────────────────────────────────────────────────────────────
154
155/// Namespace for recipe ids given without one.
156const VANILLA_NS: &str = "minecraft";
157
158/// Split `namespace:name`, falling back to `default_ns` for a bare name.
159fn split_id<'a>(id: &'a str, default_ns: &'a str) -> (&'a str, &'a str) {
160    id.split_once(':').unwrap_or((default_ns, id))
161}
162
163
164/// `s` as a JSON string.
165fn json_str(s: &str) -> String {
166    let mut out = String::with_capacity(s.len() + 2);
167    out.push('"');
168    for c in s.chars() {
169        match c {
170            '"' => out.push_str("\\\""),
171            '\\' => out.push_str("\\\\"),
172            '\n' => out.push_str("\\n"),
173            '\r' => out.push_str("\\r"),
174            '\t' => out.push_str("\\t"),
175            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
176            c => out.push(c),
177        }
178    }
179    out.push('"');
180    out
181}
182
183
184/// How a cooking recipe cooks.
185#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Cooking {
188    Smelting,
189    Blasting,
190    Smoking,
191    Campfire,
192}
193
194impl Cooking {
195    /// Its `type` in Yog's recipe format.
196    pub fn word(self) -> &'static str {
197        match self {
198            Cooking::Smelting => "smelting",
199            Cooking::Blasting => "blasting",
200            Cooking::Smoking => "smoking",
201            Cooking::Campfire => "campfire_cooking",
202        }
203    }
204}
205
206/// What a recipe is made of besides its result.
207#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
208#[derive(Debug, Clone, PartialEq)]
209pub enum RecipeKind {
210    /// A crafting grid: rows of key characters (space = empty).
211    Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
212    /// Crafting, in any arrangement.
213    Shapeless { ingredients: Vec<Vec<String>> },
214    Cooking { method: Cooking, ingredient: Vec<String>, experience: f32, cook_time: u32 },
215    Stonecutting { ingredient: Vec<String> },
216    /// A smithing table: `base` upgraded with `addition`, `template` used up.
217    Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
218}
219
220/// A recipe, in Yog's own format — what a `.yrecipe` says. The host writes
221/// the recipe JSON of its Minecraft version from it.
222///
223/// An ingredient is an item id (`minecraft:stick`), an item tag (`#c:dusts`)
224/// or — the `_any` builders — any of several.
225///
226/// ```
227/// # use yog_registry::{Cooking, Recipe};
228/// Recipe::shaped("yog:ruby_sword", "yog:ruby_sword", 1)
229///     .row(" R ").row(" R ").row(" S ")
230///     .key('R', "yog:ruby").key('S', "minecraft:stick");
231/// Recipe::cooking("yog:ruby", Cooking::Blasting, "#c:ruby_ores", "yog:ruby", 1).experience(0.7);
232/// ```
233#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
234#[derive(Debug, Clone, PartialEq)]
235pub struct Recipe {
236    pub id: String,
237    pub kind: RecipeKind,
238    pub result: String,
239    pub count: u32,
240    /// Recipes of a group share a slot in the recipe book.
241    pub group: Option<String>,
242    /// The recipe book's tab (`building`, `equipment`, `misc`, …).
243    pub category: Option<String>,
244}
245
246impl Recipe {
247    fn of(id: impl Into<String>, kind: RecipeKind, result: impl Into<String>, count: u32) -> Self {
248        Recipe { id: id.into(), kind, result: result.into(), count, group: None, category: None }
249    }
250
251    pub fn shaped(id: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
252        Self::of(id, RecipeKind::Shaped { pattern: Vec::new(), key: Vec::new() }, result, count)
253    }
254
255    pub fn shapeless(id: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
256        Self::of(id, RecipeKind::Shapeless { ingredients: Vec::new() }, result, count)
257    }
258
259    /// Cooking `ingredient` (100 % of a vanilla furnace: 0.1 experience,
260    /// 200 ticks — see [`Self::experience`], [`Self::cook_time`]).
261    pub fn cooking(
262        id: impl Into<String>,
263        method: Cooking,
264        ingredient: impl Into<String>,
265        result: impl Into<String>,
266        count: u32,
267    ) -> Self {
268        let cook_time = match method {
269            Cooking::Smelting => 200,
270            Cooking::Blasting | Cooking::Smoking => 100,
271            Cooking::Campfire => 600,
272        };
273        let kind = RecipeKind::Cooking { method, ingredient: vec![ingredient.into()], experience: 0.1, cook_time };
274        Self::of(id, kind, result, count)
275    }
276
277    pub fn stonecutting(id: impl Into<String>, ingredient: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
278        Self::of(id, RecipeKind::Stonecutting { ingredient: vec![ingredient.into()] }, result, count)
279    }
280
281    pub fn smithing(
282        id: impl Into<String>,
283        template: impl Into<String>,
284        base: impl Into<String>,
285        addition: impl Into<String>,
286        result: impl Into<String>,
287    ) -> Self {
288        let kind = RecipeKind::Smithing {
289            template: vec![template.into()],
290            base: vec![base.into()],
291            addition: vec![addition.into()],
292        };
293        Self::of(id, kind, result, 1)
294    }
295
296    /// A row of a shaped recipe's pattern.
297    pub fn row(mut self, pattern: impl Into<String>) -> Self {
298        if let RecipeKind::Shaped { pattern: p, .. } = &mut self.kind {
299            p.push(pattern.into());
300        }
301        self
302    }
303
304    /// What `symbol` stands for in a shaped recipe.
305    pub fn key(self, symbol: char, item: impl Into<String>) -> Self {
306        self.key_any(symbol, [item.into()])
307    }
308
309    /// `symbol` stands for any of `items`.
310    pub fn key_any(mut self, symbol: char, items: impl IntoIterator<Item = impl Into<String>>) -> Self {
311        if let RecipeKind::Shaped { key, .. } = &mut self.kind {
312            key.push((symbol, items.into_iter().map(Into::into).collect()));
313        }
314        self
315    }
316
317    /// One more ingredient of a shapeless recipe.
318    pub fn ingredient(self, item: impl Into<String>) -> Self {
319        self.ingredient_any([item.into()])
320    }
321
322    /// One more ingredient of a shapeless recipe: any of `items`.
323    pub fn ingredient_any(mut self, items: impl IntoIterator<Item = impl Into<String>>) -> Self {
324        if let RecipeKind::Shapeless { ingredients } = &mut self.kind {
325            ingredients.push(items.into_iter().map(Into::into).collect());
326        }
327        self
328    }
329
330    /// Experience a cooking recipe gives.
331    pub fn experience(mut self, xp: f32) -> Self {
332        if let RecipeKind::Cooking { experience, .. } = &mut self.kind {
333            *experience = xp;
334        }
335        self
336    }
337
338    /// A cooking recipe's time in ticks.
339    pub fn cook_time(mut self, ticks: u32) -> Self {
340        if let RecipeKind::Cooking { cook_time, .. } = &mut self.kind {
341            *cook_time = ticks;
342        }
343        self
344    }
345
346    pub fn group(mut self, group: impl Into<String>) -> Self {
347        self.group = Some(group.into());
348        self
349    }
350
351    pub fn category(mut self, category: impl Into<String>) -> Self {
352        self.category = Some(category.into());
353        self
354    }
355
356    /// The recipe in Yog's own format (the `.yrecipe` shape) — what
357    /// `Registry::add_recipe` hands the runtime.
358    pub fn to_data(&self) -> yog_data::Value {
359        use yog_data::{Map, Value};
360        let ing = |items: &[String]| match items {
361            [one] => Value::from(one.as_str()),
362            many => Value::List(many.iter().map(|i| Value::from(i.as_str())).collect()),
363        };
364        let mut m = Map::with_capacity(8);
365        let ty = |m: &mut Map, t: &str| m.insert("type", Value::from(t));
366        match &self.kind {
367            RecipeKind::Shaped { pattern, key } => {
368                ty(&mut m, "shaped");
369                m.insert("pattern", Value::List(pattern.iter().map(|r| Value::from(r.as_str())).collect()));
370                let mut keys = Map::with_capacity(key.len());
371                for (c, items) in key {
372                    keys.insert(c.to_string(), ing(items));
373                }
374                m.insert("key", Value::Map(keys));
375            }
376            RecipeKind::Shapeless { ingredients } => {
377                ty(&mut m, "shapeless");
378                m.insert("ingredients", Value::List(ingredients.iter().map(|i| ing(i)).collect()));
379            }
380            RecipeKind::Cooking { method, ingredient, experience, cook_time } => {
381                ty(&mut m, method.word());
382                m.insert("ingredient", ing(ingredient));
383                // As written (`0.7`, not f32's 0.699999988…).
384                m.insert("experience", Value::Float(experience.to_string().parse().unwrap_or(0.0)));
385                m.insert("cook_time", Value::Int(*cook_time as i64));
386            }
387            RecipeKind::Stonecutting { ingredient } => {
388                ty(&mut m, "stonecutting");
389                m.insert("ingredient", ing(ingredient));
390            }
391            RecipeKind::Smithing { template, base, addition } => {
392                ty(&mut m, "smithing");
393                m.insert("template", ing(template));
394                m.insert("base", ing(base));
395                m.insert("addition", ing(addition));
396            }
397        }
398        m.insert("result", Value::from(self.result.as_str()));
399        if self.count != 1 {
400            m.insert("count", Value::Int(self.count as i64));
401        }
402        if let Some(g) = &self.group {
403            m.insert("group", Value::from(g.as_str()));
404        }
405        if let Some(c) = &self.category {
406            m.insert("category", Value::from(c.as_str()));
407        }
408        Value::Map(m)
409    }
410
411    /// Split `namespace:name` from the recipe id.
412    pub fn ns_name(&self) -> (&str, &str) {
413        split_id(&self.id, VANILLA_NS)
414    }
415}
416
417// ── BookRecipe (like Patchouli's shapeless_book_recipe) ──────────────────────
418
419
420
421// ── ItemModifier ─────────────────────────────────────────────────────────────
422
423
424
425// ── StartupGrant ─────────────────────────────────────────────────────────────
426
427/// Grant items/books to every player once when they first join.
428/// This is the Yog-side replacement for `grant_patchi_book.json`.
429#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
430#[derive(yog_data::Schema, Debug, Clone)]
431pub struct StartupGrant {
432    pub id: String,
433    pub items: Vec<String>,
434    pub book: Option<String>,
435    pub command: Option<String>,
436}
437
438impl StartupGrant {
439    pub fn new(id: impl Into<String>) -> Self {
440        Self {
441            id: id.into(),
442            items: Vec::new(),
443            book: None,
444            command: None,
445        }
446    }
447
448    pub fn item(mut self, item_id: impl Into<String>) -> Self {
449        self.items.push(item_id.into());
450        self
451    }
452
453    pub fn book(mut self, book: impl Into<String>) -> Self {
454        self.book = Some(book.into());
455        self
456    }
457
458    pub fn command(mut self, cmd: impl Into<String>) -> Self {
459        self.command = Some(cmd.into());
460        self
461    }
462
463    pub fn to_json(&self) -> String {
464        let mut entries: Vec<String> = self.items.iter().map(|i| format!("{{\"item\":{}}}", json_str(i))).collect();
465        if let Some(book) = &self.book {
466            // The book's id inside SNBT inside JSON: escaped for both.
467            let tag = format!("{{yog_book:\"{}\"}}", book.replace('\\', "\\\\").replace('"', "\\\""));
468            entries.push(format!(
469                "{{\"type\":\"item\",\"name\":\"minecraft:written_book\",\"functions\":[{{\"function\":\"set_nbt\",\"tag\":{}}}]}}",
470                json_str(&tag)
471            ));
472        }
473        if let Some(cmd) = &self.command {
474            entries.push(format!("{{\"type\":\"minecraft:command\",\"command\":{}}}", json_str(cmd)));
475        }
476        format!("{{\"type\":\"yog:startup_grant\",\"entries\":[{}],\"id\":{}}}", entries.join(","), json_str(&self.id))
477    }
478}
479
480
481
482// ── Tools and armor ─────────────────────────────────────────────────────────
483
484/// An item as a tool or weapon.
485#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
486#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
487pub struct ToolItem {
488    /// `pickaxe`, `axe`, `shovel`, `hoe`, `sword`, or the mineable tag of
489    /// a mod's kind of tool (`mymod:mineable/hammer`).
490    pub kind: String,
491    /// Its [`ToolMaterial`]'s id (`minecraft:iron`, `mymod:mythril`).
492    pub material: String,
493    /// Attack damage on top of the material's (an iron pickaxe: 1).
494    pub attack_damage: f32,
495    /// Attack speed change (a pickaxe: -2.8, a sword: -2.4).
496    pub attack_speed: f32,
497}
498
499/// An item as a piece of armor.
500#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
501#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
502pub struct ArmorPiece {
503    /// `head`, `chest`, `legs` or `feet`.
504    pub slot: String,
505    /// Its [`ArmorMaterial`]'s id.
506    pub material: String,
507}
508
509/// What tools are made of. The game's are built in
510/// ([`ToolMaterial::built_ins`]); a mod declares its own in a `.ydef`
511/// (`kind: "tool_material"`) or registers one
512/// (`Registry::register_tool_material`).
513#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
514#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
515pub struct ToolMaterial {
516    pub id: String,
517    /// Uses before it breaks.
518    pub durability: u32,
519    /// How fast it mines (wood 2 … netherite 9, gold 12).
520    pub speed: f32,
521    /// Attack damage it adds (wood 0 … netherite 4).
522    pub attack_damage: f32,
523    pub enchantability: u32,
524    /// Its level on the tool ladder (`minecraft:iron`, `mymod:mythril`).
525    pub level: String,
526    /// What repairs it: an item id or `#tag`.
527    pub repair: String,
528    /// Minecraft 1.20's level number of `level` (wood 0 … netherite 4).
529    #[data(default)]
530    pub level_index: u32,
531    /// Minecraft 1.21's tag of the blocks a tool of it can't mine for
532    /// drops (`minecraft:incorrect_for_iron_tool`).
533    #[data(default)]
534    pub incorrect: String,
535}
536
537/// Defense points per armor piece.
538#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
539#[derive(yog_data::Schema, Debug, Clone, Copy, PartialEq)]
540pub struct ArmorDefense {
541    pub head: u32,
542    pub chest: u32,
543    pub legs: u32,
544    pub feet: u32,
545}
546
547/// What armor is made of. The game's are built in
548/// ([`ArmorMaterial::built_ins`]); a mod declares its own in a `.ydef`
549/// (`kind: "armor_material"`) or registers one.
550#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
551#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
552pub struct ArmorMaterial {
553    pub id: String,
554    /// Multiplies each piece's base durability (helmet 11, chestplate 16,
555    /// leggings 15, boots 13).
556    pub durability: u32,
557    pub defense: ArmorDefense,
558    pub enchantability: u32,
559    /// The sound event of putting it on.
560    pub equip_sound: String,
561    #[data(default)]
562    pub toughness: f32,
563    #[data(default)]
564    pub knockback_resistance: f32,
565    /// What repairs it: an item id or `#tag`.
566    pub repair: String,
567    /// Its look on a player: `<ns>:<name>` is
568    /// `<ns>:textures/models/armor/<name>_layer_1.png` (and `_layer_2`).
569    pub texture: String,
570}
571
572static BUILT_IN_TOOL_MATERIALS: std::sync::LazyLock<Vec<ToolMaterial>> = std::sync::LazyLock::new(|| {
573    yog_data::from_text::<Vec<ToolMaterial>>(include_str!("../assets/tool_materials.ydata"))
574        .unwrap_or_else(|e| panic!("yog-registry's built-in tool materials: {e}"))
575        .value
576});
577
578static BUILT_IN_ARMOR_MATERIALS: std::sync::LazyLock<Vec<ArmorMaterial>> = std::sync::LazyLock::new(|| {
579    yog_data::from_text::<Vec<ArmorMaterial>>(include_str!("../assets/armor_materials.ydata"))
580        .unwrap_or_else(|e| panic!("yog-registry's built-in armor materials: {e}"))
581        .value
582});
583
584/// One of `list` by short name or id (`iron` = `minecraft:iron`).
585fn built_in<'a, T>(list: &'a [T], id: impl Fn(&T) -> &str, name: &str) -> Option<&'a T> {
586    let name = name.strip_prefix("minecraft:").unwrap_or(name);
587    list.iter().find(|x| id(x).strip_prefix("minecraft:") == Some(name))
588}
589
590impl ToolMaterial {
591    /// The game's materials: wood, stone, iron, diamond, gold, netherite.
592    pub fn built_ins() -> &'static [ToolMaterial] {
593        &BUILT_IN_TOOL_MATERIALS
594    }
595
596    /// One of the game's, by short name or id.
597    pub fn built_in(name: &str) -> Option<&'static ToolMaterial> {
598        built_in(Self::built_ins(), |m| &m.id, name)
599    }
600
601    /// `id` with `like`'s level (one of the game's: `iron`), durability,
602    /// speed, damage, enchantability and repair — change what differs.
603    pub fn like(id: impl Into<String>, like: &str) -> Option<Self> {
604        let mut m = Self::built_in(like)?.clone();
605        m.id = id.into();
606        Some(m)
607    }
608}
609
610impl ArmorMaterial {
611    /// The game's materials: leather, chainmail, iron, gold, diamond, turtle, netherite.
612    pub fn built_ins() -> &'static [ArmorMaterial] {
613        &BUILT_IN_ARMOR_MATERIALS
614    }
615
616    /// One of the game's, by short name or id.
617    pub fn built_in(name: &str) -> Option<&'static ArmorMaterial> {
618        built_in(Self::built_ins(), |m| &m.id, name)
619    }
620}
621
622// ── World generation ────────────────────────────────────────────────────────
623
624/// A placed feature (`mymod:mythril_ore`, the mod's datapack's) added to
625/// biomes at a generation step — what an ore `.ydef` makes. Forge and
626/// NeoForge read the biome modifiers `yog build` writes; Fabric's host adds
627/// these through its biome API.
628#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
629#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
630pub struct BiomeFeature {
631    /// The placed feature's id.
632    pub feature: String,
633    /// The tag of biomes it goes in (`minecraft:is_overworld`).
634    pub biomes: String,
635    /// The generation step (`underground_ores`, `vegetal_decoration`, …).
636    pub step: String,
637}
638
639// ── Sound groups ─────────────────────────────────────────────────────────────
640
641/// What a block sounds like: the sound events it plays when broken,
642/// stepped on, placed, hit while mining and fallen on. The game's groups
643/// are built in ([`SoundGroup::built_in`]); a mod registers its own
644/// (`Registry::register_sound_group`, or a `.ydef` of kind `sound_group`)
645/// and names it in `BlockDef::sound`.
646#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
647#[derive(yog_data::Schema, Debug, Clone, PartialEq)]
648pub struct SoundGroup {
649    pub id: String,
650    /// Sound event ids (`minecraft:block.stone.break`, `mymod:crystal.break`).
651    #[data(rename = "break")]
652    pub break_sound: String,
653    pub step: String,
654    pub place: String,
655    pub hit: String,
656    pub fall: String,
657    #[data(default = "one")]
658    #[data(range = "0..=10")]
659    pub volume: f32,
660    #[data(default = "one")]
661    #[data(range = "0..=10")]
662    pub pitch: f32,
663}
664
665fn one() -> f32 {
666    1.0
667}
668
669static BUILT_IN_SOUND_GROUPS: std::sync::LazyLock<Vec<SoundGroup>> = std::sync::LazyLock::new(|| {
670    yog_data::from_text::<Vec<SoundGroup>>(include_str!("../assets/sound_groups.ydata"))
671        .unwrap_or_else(|e| panic!("yog-registry's built-in sound groups: {e}"))
672        .value
673});
674
675impl SoundGroup {
676    /// `mymod:crystal`, its sounds `mymod:crystal.break`, `.step`, `.place`,
677    /// `.hit`, `.fall`; volume and pitch 1.
678    pub fn new(id: impl Into<String>) -> Self {
679        let id = id.into();
680        let ev = |s: &str| format!("{id}.{s}");
681        Self { break_sound: ev("break"), step: ev("step"), place: ev("place"), hit: ev("hit"), fall: ev("fall"), volume: 1.0, pitch: 1.0, id }
682    }
683
684    /// The five sound events: break, step, place, hit, fall.
685    pub fn sounds(mut self, break_sound: &str, step: &str, place: &str, hit: &str, fall: &str) -> Self {
686        [self.break_sound, self.step, self.place, self.hit, self.fall] = [break_sound, step, place, hit, fall].map(String::from);
687        self
688    }
689
690    pub fn volume(mut self, volume: f32) -> Self {
691        self.volume = volume;
692        self
693    }
694
695    pub fn pitch(mut self, pitch: f32) -> Self {
696        self.pitch = pitch;
697        self
698    }
699
700    /// The game's groups (`minecraft:stone`, `minecraft:amethyst`…, from
701    /// Minecraft's `SoundType`), as data.
702    pub fn built_ins() -> &'static [SoundGroup] {
703        &BUILT_IN_SOUND_GROUPS
704    }
705
706    /// One of the game's groups, by short name or id (`stone` = `minecraft:stone`).
707    pub fn built_in(name: &str) -> Option<&'static SoundGroup> {
708        let name = name.strip_prefix("minecraft:").unwrap_or(name);
709        Self::built_ins().iter().find(|g| g.id.strip_prefix("minecraft:") == Some(name))
710    }
711}
712
713// ── Blocks ───────────────────────────────────────────────────────────────────
714
715/// A custom block to register; it also gets a matching block-item.
716#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
717#[derive(yog_data::Schema, Debug, Clone)]
718pub struct BlockDef {
719    pub id: String,
720    pub hardness: f32,
721    pub resistance: f32,
722    pub name: Option<String>,
723    /// Optional collision/outline box in pixel units (0–16): `[x1,y1,z1,x2,y2,z2]`.
724    /// `None` = full cube.
725    pub shape: Option<[f32; 6]>,
726    /// Light emitted by this block (0 = none, 15 = max, like a torch).
727    pub light_level: u8,
728    /// Its [`SoundGroup`]: one of the game's by name (`"stone"`,
729    /// `"amethyst"`, `"copper"` — [`SoundGroup::built_in`]) or a mod's
730    /// (`"mymod:crystal"`). `None` = stone (Minecraft default).
731    pub sound: Option<String>,
732    /// If `true`, the correct tool (from the block's tags) is required for drops.
733    pub requires_tool: bool,
734    /// If `true`, entities pass through this block (like flowers or torches).
735    pub no_collision: bool,
736    /// Friction coefficient. `0.0` = default (0.6). Ice = 0.989.
737    pub slipperiness: f32,
738    /// If `true`, this block dynamically grows arms toward neighbors it's
739    /// compatible with (fence/pipe-style): the Java host tracks N/S/E/W/U/D
740    /// boolean blockstate properties, recomputed on placement and on
741    /// neighbor change, and grows the collision/outline shape from the
742    /// `shape` core box (or a default post) out to each connected side.
743    pub connects: bool,
744    /// Connection compatibility tags (configured in code via
745    /// `.connect_groups(&[...])` — see that method's docs for how this
746    /// drives which blocks link up). Independent of `connects`: a block can
747    /// carry tags purely as a connection *target* (e.g. an ALU accepting a
748    /// Digital Cable) without dynamically growing its own shape.
749    pub connect_groups: Vec<String>,
750    /// Id of a `YogInventoryDef` this block is backed by — `None` = plain
751    /// block (default). See `yog_inventory`'s DESIGN.md.
752    pub inventory_id: Option<String>,
753    /// Gives off redstone power — how much, per position, the mod sets with
754    /// `Server::set_redstone_output`.
755    pub redstone_source: bool,
756    /// How it turns when placed (a `facing` property): `""` (it doesn't),
757    /// `"horizontal"` or `"all"`.
758    pub facing: String,
759    /// `"toward"`: its front toward the placer; `"away"`: the way they look.
760    pub placement: String,
761    /// It hangs on a floor, wall or ceiling, as a button (a `face`
762    /// property and a horizontal `facing`).
763    pub attach: bool,
764    /// It can stand in water (a `waterlogged` property).
765    pub waterlogged: bool,
766    /// Properties of its own.
767    pub properties: Vec<BlockProperty>,
768    /// A yes/no property the block keeps on while it gets redstone power.
769    pub powered: Option<String>,
770    /// A yes/no property a comparator reads 15 from while it's on.
771    pub comparator: Option<String>,
772    /// Hoppers and droppers can put items into it (`on_item_insert`).
773    pub accepts_items: bool,
774}
775
776/// A property of a block's own: its name and values (the first the
777/// default; `false`, `true` is a yes/no).
778#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
779#[derive(yog_data::Schema, Debug, Clone, PartialEq, Eq)]
780pub struct BlockProperty {
781    pub name: String,
782    pub values: Vec<String>,
783}
784
785impl BlockDef {
786    pub fn new(id: impl Into<String>) -> Self {
787        Self {
788            id: id.into(),
789            hardness: 1.5,
790            resistance: 6.0,
791            name: None,
792            shape: None,
793            light_level: 0,
794            sound: None,
795            requires_tool: false,
796            no_collision: false,
797            slipperiness: 0.0,
798            connects: false,
799            connect_groups: Vec::new(),
800            inventory_id: None,
801            redstone_source: false,
802            facing: String::new(),
803            placement: String::new(),
804            attach: false,
805            waterlogged: false,
806            properties: Vec::new(),
807            powered: None,
808            comparator: None,
809            accepts_items: false,
810        }
811    }
812
813    /// Mining hardness and blast resistance (defaults 1.5 / 6.0).
814    pub fn strength(mut self, hardness: f32, resistance: f32) -> Self {
815        self.hardness = hardness;
816        self.resistance = resistance;
817        self
818    }
819
820    /// Display name shown in-game.
821    pub fn name(mut self, name: impl Into<String>) -> Self {
822        self.name = Some(name.into());
823        self
824    }
825
826    /// Custom hitbox/outline in pixel units (0–16).
827    pub fn shape(mut self, x1: f32, y1: f32, z1: f32, x2: f32, y2: f32, z2: f32) -> Self {
828        self.shape = Some([x1, y1, z1, x2, y2, z2]);
829        self
830    }
831
832    /// Emitted light level (0–15).
833    pub fn light_level(mut self, level: u8) -> Self {
834        self.light_level = level.min(15);
835        self
836    }
837
838    /// Sound group: one of the game's (`"stone"`, `"amethyst"`…) or a
839    /// mod's [`SoundGroup`] (`"mymod:crystal"`).
840    pub fn sound(mut self, group: impl Into<String>) -> Self {
841        self.sound = Some(group.into());
842        self
843    }
844
845    /// Correct tool required for loot drops (equivalent to `requiresTool()`).
846    pub fn requires_tool(mut self) -> Self {
847        self.requires_tool = true;
848        self
849    }
850
851    /// No physical collision — entities pass through (like flowers).
852    pub fn no_collision(mut self) -> Self {
853        self.no_collision = true;
854        self
855    }
856
857    /// Friction (default 0.6). Set to 0.989 for ice-like slipperiness.
858    pub fn slipperiness(mut self, value: f32) -> Self {
859        self.slipperiness = value;
860        self
861    }
862
863    /// Dynamically grow arms toward compatible neighbors — fence/pipe-style.
864    /// "Compatible" means the neighbor also has a `connect_groups` tag in
865    /// common (set here, or via `connect_groups` alone on a static target
866    /// block). Call `.connect_groups(&[...])` too, or this connects to
867    /// nothing. See the `connects` field doc.
868    pub fn connects_to_neighbors(mut self) -> Self {
869        self.connects = true;
870        self
871    }
872
873    /// Connection compatibility tags — two blocks link up (for
874    /// `connects_to_neighbors` arm growth, and as valid connection targets
875    /// generally) when their tag sets share at least one entry. Example:
876    /// `analog_cable` and `redstone_port` both carry `"analog"`;
877    /// `digital_cable` carries `"digital"`; an ALU block carries both, so it
878    /// accepts either cable while a Redstone Port only accepts the analog one.
879    pub fn connect_groups(mut self, groups: &[&str]) -> Self {
880        self.connect_groups = groups
881            .iter()
882            .map(std::string::ToString::to_string)
883            .collect();
884        self
885    }
886
887    /// Back this block with a real Container/Menu inventory screen, whose
888    /// shape/layout was registered separately via `Registry::register_inventory`
889    /// — see `yog_inventory::InventoryDef`. `id` is that def's id.
890    /// A redstone power source: what each placed block gives off is set
891    /// with `Server::set_redstone_output` (0 until then).
892    pub fn redstone_source(mut self) -> Self {
893        self.redstone_source = true;
894        self
895    }
896
897    /// Turns when placed: `"horizontal"` or `"all"` ways, its front (the
898    /// model's north side) `"toward"` the placer or `"away"` (the way they look).
899    pub fn facing(mut self, ways: &str, placement: &str) -> Self {
900        self.facing = ways.to_string();
901        self.placement = placement.to_string();
902        self
903    }
904
905    /// Hangs on a floor, wall or ceiling, as a button.
906    pub fn attach(mut self) -> Self {
907        self.attach = true;
908        self
909    }
910
911    /// Can stand in water.
912    pub fn waterlogged(mut self) -> Self {
913        self.waterlogged = true;
914        self
915    }
916
917    /// A property of its own: `values`, the first the default.
918    pub fn property(mut self, name: &str, values: &[&str]) -> Self {
919        self.properties.push(BlockProperty { name: name.into(), values: values.iter().map(|v| v.to_string()).collect() });
920        self
921    }
922
923    /// Keeps yes/no property `name` on while it gets redstone power.
924    pub fn powered(mut self, name: &str) -> Self {
925        self.powered = Some(name.into());
926        self
927    }
928
929    /// A comparator reads 15 from it while yes/no property `name` is on.
930    pub fn comparator(mut self, name: &str) -> Self {
931        self.comparator = Some(name.into());
932        self
933    }
934
935    /// Hoppers and droppers can put items into it (`on_item_insert`).
936    pub fn accepts_items(mut self) -> Self {
937        self.accepts_items = true;
938        self
939    }
940
941    pub fn inventory(mut self, id: impl Into<String>) -> Self {
942        self.inventory_id = Some(id.into());
943        self
944    }
945}
946
947#[cfg(test)]
948mod material_tests {
949    use super::*;
950
951    #[test]
952    fn the_games_materials_are_built_in() {
953        assert_eq!(ToolMaterial::built_ins().len(), 6);
954        let iron = ToolMaterial::built_in("iron").unwrap();
955        assert_eq!((iron.durability, iron.level_index, iron.incorrect.as_str()), (250, 2, "minecraft:incorrect_for_iron_tool"));
956        assert_eq!(ArmorMaterial::built_in("minecraft:netherite").unwrap().knockback_resistance, 0.1);
957        assert_eq!(ArmorMaterial::built_in("diamond").unwrap().defense.chest, 8);
958        let mythril = ToolMaterial::like("mymod:mythril", "diamond").unwrap();
959        assert_eq!((mythril.id.as_str(), mythril.durability), ("mymod:mythril", 1561));
960    }
961}
962
963#[cfg(test)]
964mod sound_tests {
965    use super::*;
966
967    #[test]
968    fn the_games_sound_groups_are_built_in() {
969        assert!(SoundGroup::built_ins().len() > 100);
970        let stone = SoundGroup::built_in("stone").unwrap();
971        assert_eq!((stone.break_sound.as_str(), stone.volume), ("minecraft:block.stone.break", 1.0));
972        assert_eq!(SoundGroup::built_in("minecraft:metal").unwrap().pitch, 1.5);
973        assert!(SoundGroup::built_in("mymod:crystal").is_none());
974    }
975
976    #[test]
977    fn a_mods_group_crosses_as_yog_data() {
978        use yog_data::Schema;
979        let g = SoundGroup::new("mymod:crystal").pitch(1.2);
980        assert_eq!(g.fall, "mymod:crystal.fall");
981        let back = yog_data::from_value::<SoundGroup>(&g.write()).unwrap().value;
982        assert_eq!(back, g);
983        assert_eq!(g.write().get("break").and_then(|v| v.as_str()), Some("mymod:crystal.break"));
984    }
985}
986
987#[cfg(test)]
988impl Recipe {
989    fn to_json(&self) -> String {
990        yog_data::json::to_json(&self.to_data()).to_string()
991    }
992}
993
994#[cfg(test)]
995mod recipe_tests {
996    use super::*;
997
998    #[test]
999    fn writes_the_yog_format() {
1000        let js = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
1001        let shaped = Recipe::shaped("m:a", "m:b", 2).row("D ").key('D', "#c:dusts").key_any('E', ["m:x", "m:y"]).group("g");
1002        assert_eq!(
1003            js(&shaped.to_json()),
1004            js(r##"{"type":"shaped","pattern":["D "],"key":{"D":"#c:dusts","E":["m:x","m:y"]},"result":"m:b","count":2,"group":"g"}"##)
1005        );
1006        assert_eq!(shaped.to_data().as_map().unwrap().keys().next(), Some("type"), "the type first");
1007        let blast = Recipe::cooking("m:a", Cooking::Blasting, "#c:ores", "m:b", 1).experience(0.7);
1008        assert_eq!(js(&blast.to_json()), js(r##"{"type":"blasting","ingredient":"#c:ores","experience":0.7,"cook_time":100,"result":"m:b"}"##));
1009        let smith = Recipe::smithing("m:a", "m:t", "m:base", "m:add", "m:out");
1010        assert_eq!(js(&smith.to_json())["template"], "m:t");
1011    }
1012}
1013
1014#[cfg(test)]
1015mod json_tests {
1016    use super::*;
1017
1018    /// Every `to_json` makes valid JSON, whatever the strings hold.
1019    #[test]
1020    fn json_survives_any_string() {
1021        let odd = "m:a\"b\\c\nd\te\u{1}";
1022        let parse = |j: String| serde_json::from_str::<serde_json::Value>(&j).unwrap_or_else(|e| panic!("{e}: {j}"));
1023        for r in [
1024            Recipe::shaped(odd, odd, 1).row("A").key('A', odd).group(odd).category(odd),
1025            Recipe::shapeless(odd, odd, 3).ingredient(odd).ingredient_any([odd, "m:x"]),
1026            Recipe::cooking(odd, Cooking::Smoking, odd, odd, 1).cook_time(5),
1027            Recipe::stonecutting(odd, odd, odd, 2),
1028            Recipe::smithing(odd, odd, odd, odd, odd),
1029        ] {
1030            let v = parse(r.to_json());
1031            assert_eq!(v["result"], odd, "the string comes back as it was");
1032        }
1033        let g = StartupGrant::new(odd).item(odd).book("mymod:\"guide\"").command("say \"hi\" \\o/");
1034        let v = parse(g.to_json());
1035        assert_eq!(v["id"], odd);
1036        assert_eq!(v["entries"][2]["command"], "say \"hi\" \\o/");
1037    }
1038
1039    #[test]
1040    fn recipes_and_grants_name_their_parts() {
1041        let r = Recipe::cooking("mymod:a", Cooking::Campfire, "minecraft:beef", "minecraft:cooked_beef", 1);
1042        let v: serde_json::Value = serde_json::from_str(&r.to_json()).unwrap();
1043        assert_eq!(v["type"], "campfire_cooking");
1044        assert_eq!(v["cook_time"], 600, "the method's default time");
1045        assert_eq!(r.ns_name(), ("mymod", "a"));
1046        assert_eq!(Recipe::shaped("plain", "x", 1).ns_name().1, "plain");
1047        let g = StartupGrant::new("mymod:starter");
1048        assert!(g.to_json().contains(r#""entries":[]"#));
1049    }
1050}