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