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(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(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}
65
66impl ItemDef {
67    pub fn new(id: impl Into<String>) -> Self {
68        Self {
69            id: id.into(),
70            max_stack: 64,
71            name: None,
72            tooltip: None,
73            max_damage: 0,
74            fire_resistant: false,
75            fuel_ticks: 0,
76            food: None,
77        }
78    }
79
80    /// Maximum stack size (default 64). Ignored when `max_damage` is set.
81    pub fn max_stack(mut self, n: u8) -> Self {
82        self.max_stack = n;
83        self
84    }
85
86    /// Display name shown in-game.
87    pub fn name(mut self, name: impl Into<String>) -> Self {
88        self.name = Some(name.into());
89        self
90    }
91
92    /// A tooltip line shown on hover.
93    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
94        self.tooltip = Some(tooltip.into());
95        self
96    }
97
98    /// Make this a damageable item (tool/weapon/armour). Forces stack size to 1.
99    pub fn max_damage(mut self, durability: u32) -> Self {
100        self.max_damage = durability;
101        self
102    }
103
104    /// Make this item fire-resistant (won't burn in fire or lava).
105    pub fn fire_resistant(mut self) -> Self {
106        self.fire_resistant = true;
107        self
108    }
109
110    /// Register as furnace fuel burning for `ticks` (200 ticks = 1 item smelted).
111    pub fn fuel(mut self, ticks: u32) -> Self {
112        self.fuel_ticks = ticks;
113        self
114    }
115
116    /// Make this item edible with the given nutritional properties.
117    pub fn food(mut self, food: FoodDef) -> Self {
118        self.food = Some(food);
119        self
120    }
121}
122
123// ── Recipes ──────────────────────────────────────────────────────────────────
124
125/// Namespace for recipe ids given without one.
126const VANILLA_NS: &str = "minecraft";
127/// Namespace for Yog-defined recipe types' ids given without one.
128const YOG_NS: &str = "yog";
129
130/// Split `namespace:name`, falling back to `default_ns` for a bare name.
131fn split_id<'a>(id: &'a str, default_ns: &'a str) -> (&'a str, &'a str) {
132    id.split_once(':').unwrap_or((default_ns, id))
133}
134
135/// `{"item":"a"},{"item":"b"}` — an ingredient list's JSON body.
136/// A recipe ingredient: `ns:item` → `{"item":…}`, `#ns:tag` → `{"tag":…}`.
137fn ingredient_json(id: &str) -> String {
138    match id.strip_prefix('#') {
139        Some(tag) => format!("{{\"tag\":\"{tag}\"}}"),
140        None => format!("{{\"item\":\"{id}\"}}"),
141    }
142}
143
144/// `s` as a JSON string.
145fn json_str(s: &str) -> String {
146    let mut out = String::with_capacity(s.len() + 2);
147    out.push('"');
148    for c in s.chars() {
149        match c {
150            '"' => out.push_str("\\\""),
151            '\\' => out.push_str("\\\\"),
152            c => out.push(c),
153        }
154    }
155    out.push('"');
156    out
157}
158
159fn item_list_json(items: &[String]) -> String {
160    items
161        .iter()
162        .map(|i| ingredient_json(i))
163        .collect::<Vec<_>>()
164        .join(",")
165}
166
167/// How a cooking recipe cooks.
168#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Cooking {
171    Smelting,
172    Blasting,
173    Smoking,
174    Campfire,
175}
176
177impl Cooking {
178    /// Its `type` in Yog's recipe format.
179    pub fn word(self) -> &'static str {
180        match self {
181            Cooking::Smelting => "smelting",
182            Cooking::Blasting => "blasting",
183            Cooking::Smoking => "smoking",
184            Cooking::Campfire => "campfire_cooking",
185        }
186    }
187}
188
189/// What a recipe is made of besides its result.
190#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
191#[derive(Debug, Clone, PartialEq)]
192pub enum RecipeKind {
193    /// A crafting grid: rows of key characters (space = empty).
194    Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
195    /// Crafting, in any arrangement.
196    Shapeless { ingredients: Vec<Vec<String>> },
197    Cooking { method: Cooking, ingredient: Vec<String>, experience: f32, cook_time: u32 },
198    Stonecutting { ingredient: Vec<String> },
199    /// A smithing table: `base` upgraded with `addition`, `template` used up.
200    Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
201}
202
203/// A recipe, in Yog's own format — what a `.yrecipe` says. The host writes
204/// the recipe JSON of its Minecraft version from it.
205///
206/// An ingredient is an item id (`minecraft:stick`), an item tag (`#c:dusts`)
207/// or — the `_any` builders — any of several.
208///
209/// ```
210/// # use yog_registry::{Cooking, Recipe};
211/// Recipe::shaped("yog:ruby_sword", "yog:ruby_sword", 1)
212///     .row(" R ").row(" R ").row(" S ")
213///     .key('R', "yog:ruby").key('S', "minecraft:stick");
214/// Recipe::cooking("yog:ruby", Cooking::Blasting, "#c:ruby_ores", "yog:ruby", 1).experience(0.7);
215/// ```
216#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
217#[derive(Debug, Clone, PartialEq)]
218pub struct Recipe {
219    pub id: String,
220    pub kind: RecipeKind,
221    pub result: String,
222    pub count: u32,
223    /// Recipes of a group share a slot in the recipe book.
224    pub group: Option<String>,
225    /// The recipe book's tab (`building`, `equipment`, `misc`, …).
226    pub category: Option<String>,
227}
228
229impl Recipe {
230    fn of(id: impl Into<String>, kind: RecipeKind, result: impl Into<String>, count: u32) -> Self {
231        Recipe { id: id.into(), kind, result: result.into(), count, group: None, category: None }
232    }
233
234    pub fn shaped(id: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
235        Self::of(id, RecipeKind::Shaped { pattern: Vec::new(), key: Vec::new() }, result, count)
236    }
237
238    pub fn shapeless(id: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
239        Self::of(id, RecipeKind::Shapeless { ingredients: Vec::new() }, result, count)
240    }
241
242    /// Cooking `ingredient` (100 % of a vanilla furnace: 0.1 experience,
243    /// 200 ticks — see [`Self::experience`], [`Self::cook_time`]).
244    pub fn cooking(
245        id: impl Into<String>,
246        method: Cooking,
247        ingredient: impl Into<String>,
248        result: impl Into<String>,
249        count: u32,
250    ) -> Self {
251        let cook_time = match method {
252            Cooking::Smelting => 200,
253            Cooking::Blasting | Cooking::Smoking => 100,
254            Cooking::Campfire => 600,
255        };
256        let kind = RecipeKind::Cooking { method, ingredient: vec![ingredient.into()], experience: 0.1, cook_time };
257        Self::of(id, kind, result, count)
258    }
259
260    pub fn stonecutting(id: impl Into<String>, ingredient: impl Into<String>, result: impl Into<String>, count: u32) -> Self {
261        Self::of(id, RecipeKind::Stonecutting { ingredient: vec![ingredient.into()] }, result, count)
262    }
263
264    pub fn smithing(
265        id: impl Into<String>,
266        template: impl Into<String>,
267        base: impl Into<String>,
268        addition: impl Into<String>,
269        result: impl Into<String>,
270    ) -> Self {
271        let kind = RecipeKind::Smithing {
272            template: vec![template.into()],
273            base: vec![base.into()],
274            addition: vec![addition.into()],
275        };
276        Self::of(id, kind, result, 1)
277    }
278
279    /// A row of a shaped recipe's pattern.
280    pub fn row(mut self, pattern: impl Into<String>) -> Self {
281        if let RecipeKind::Shaped { pattern: p, .. } = &mut self.kind {
282            p.push(pattern.into());
283        }
284        self
285    }
286
287    /// What `symbol` stands for in a shaped recipe.
288    pub fn key(self, symbol: char, item: impl Into<String>) -> Self {
289        self.key_any(symbol, [item.into()])
290    }
291
292    /// `symbol` stands for any of `items`.
293    pub fn key_any(mut self, symbol: char, items: impl IntoIterator<Item = impl Into<String>>) -> Self {
294        if let RecipeKind::Shaped { key, .. } = &mut self.kind {
295            key.push((symbol, items.into_iter().map(Into::into).collect()));
296        }
297        self
298    }
299
300    /// One more ingredient of a shapeless recipe.
301    pub fn ingredient(self, item: impl Into<String>) -> Self {
302        self.ingredient_any([item.into()])
303    }
304
305    /// One more ingredient of a shapeless recipe: any of `items`.
306    pub fn ingredient_any(mut self, items: impl IntoIterator<Item = impl Into<String>>) -> Self {
307        if let RecipeKind::Shapeless { ingredients } = &mut self.kind {
308            ingredients.push(items.into_iter().map(Into::into).collect());
309        }
310        self
311    }
312
313    /// Experience a cooking recipe gives.
314    pub fn experience(mut self, xp: f32) -> Self {
315        if let RecipeKind::Cooking { experience, .. } = &mut self.kind {
316            *experience = xp;
317        }
318        self
319    }
320
321    /// A cooking recipe's time in ticks.
322    pub fn cook_time(mut self, ticks: u32) -> Self {
323        if let RecipeKind::Cooking { cook_time, .. } = &mut self.kind {
324            *cook_time = ticks;
325        }
326        self
327    }
328
329    pub fn group(mut self, group: impl Into<String>) -> Self {
330        self.group = Some(group.into());
331        self
332    }
333
334    pub fn category(mut self, category: impl Into<String>) -> Self {
335        self.category = Some(category.into());
336        self
337    }
338
339    /// The recipe in Yog's own format (the `.yrecipe` shape, compact).
340    pub fn to_json(&self) -> String {
341        let ing = |items: &[String]| match items {
342            [one] => json_str(one),
343            many => format!("[{}]", many.iter().map(|i| json_str(i)).collect::<Vec<_>>().join(",")),
344        };
345        let mut fields = Vec::new();
346        match &self.kind {
347            RecipeKind::Shaped { pattern, key } => {
348                fields.push("\"type\":\"shaped\"".to_string());
349                let rows: Vec<String> = pattern.iter().map(|r| json_str(r)).collect();
350                fields.push(format!("\"pattern\":[{}]", rows.join(",")));
351                let keys: Vec<String> = key.iter().map(|(c, items)| format!("{}:{}", json_str(&c.to_string()), ing(items))).collect();
352                fields.push(format!("\"key\":{{{}}}", keys.join(",")));
353            }
354            RecipeKind::Shapeless { ingredients } => {
355                fields.push("\"type\":\"shapeless\"".to_string());
356                let list: Vec<String> = ingredients.iter().map(|i| ing(i)).collect();
357                fields.push(format!("\"ingredients\":[{}]", list.join(",")));
358            }
359            RecipeKind::Cooking { method, ingredient, experience, cook_time } => {
360                fields.push(format!("\"type\":\"{}\"", method.word()));
361                fields.push(format!("\"ingredient\":{}", ing(ingredient)));
362                fields.push(format!("\"experience\":{experience}"));
363                fields.push(format!("\"cook_time\":{cook_time}"));
364            }
365            RecipeKind::Stonecutting { ingredient } => {
366                fields.push("\"type\":\"stonecutting\"".to_string());
367                fields.push(format!("\"ingredient\":{}", ing(ingredient)));
368            }
369            RecipeKind::Smithing { template, base, addition } => {
370                fields.push("\"type\":\"smithing\"".to_string());
371                fields.push(format!("\"template\":{}", ing(template)));
372                fields.push(format!("\"base\":{}", ing(base)));
373                fields.push(format!("\"addition\":{}", ing(addition)));
374            }
375        }
376        fields.push(format!("\"result\":{}", json_str(&self.result)));
377        if self.count != 1 {
378            fields.push(format!("\"count\":{}", self.count));
379        }
380        if let Some(g) = &self.group {
381            fields.push(format!("\"group\":{}", json_str(g)));
382        }
383        if let Some(c) = &self.category {
384            fields.push(format!("\"category\":{}", json_str(c)));
385        }
386        format!("{{{}}}", fields.join(","))
387    }
388
389    /// Split `namespace:name` from the recipe id.
390    pub fn ns_name(&self) -> (&str, &str) {
391        split_id(&self.id, VANILLA_NS)
392    }
393}
394
395// ── BookRecipe (like Patchouli's shapeless_book_recipe) ──────────────────────
396
397/// A shapeless recipe that produces a book from `yog-book`.
398/// Replaces `patchouli:shapeless_book_recipe`.
399#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
400#[derive(Debug, Clone)]
401pub struct BookRecipe {
402    pub id: String,
403    pub book: String,
404    pub ingredients: Vec<String>,
405}
406
407impl BookRecipe {
408    pub fn new(id: impl Into<String>, book: impl Into<String>) -> Self {
409        Self {
410            id: id.into(),
411            book: book.into(),
412            ingredients: Vec::new(),
413        }
414    }
415
416    pub fn ingredient(mut self, item_id: impl Into<String>) -> Self {
417        self.ingredients.push(item_id.into());
418        self
419    }
420
421    pub fn to_json(&self) -> String {
422        let ingr = item_list_json(&self.ingredients);
423        format!(
424            "{{\"type\":\"yog:crafting_book\",\"ingredients\":[{}],\"book\":\"{}\"}}",
425            ingr, self.book
426        )
427    }
428
429    pub fn ns_name(&self) -> (&str, &str) {
430        split_id(&self.id, YOG_NS)
431    }
432}
433
434// ── ItemModifier ─────────────────────────────────────────────────────────────
435
436/// An item modifier applied during loot generation (like smelting, enchanted, etc.).
437#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
438#[derive(Debug, Clone)]
439pub struct ItemModifier {
440    pub id: String,
441    /// Modifier function identifier, e.g. "yog:set_count" or "example:shard_reducer".
442    pub function: String,
443    /// Parameters as JSON object.
444    pub parameters: std::collections::HashMap<String, String>,
445}
446
447impl ItemModifier {
448    pub fn new(id: impl Into<String>, function: impl Into<String>) -> Self {
449        Self {
450            id: id.into(),
451            function: function.into(),
452            parameters: std::collections::HashMap::new(),
453        }
454    }
455
456    pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
457        self.parameters.insert(key.into(), value.into());
458        self
459    }
460
461    pub fn to_json(&self) -> String {
462        let params: String = self
463            .parameters
464            .iter()
465            .map(|(k, v)| format!("\"{k}\":{v}"))
466            .collect::<Vec<_>>()
467            .join(",");
468        format!(
469            "{{\"function\":\"{}\",{}}}",
470            self.function,
471            if params.is_empty() {
472                String::new()
473            } else {
474                params
475            }
476        )
477    }
478}
479
480// ── StartupGrant ─────────────────────────────────────────────────────────────
481
482/// Grant items/books to every player once when they first join.
483/// This is the Yog-side replacement for `grant_patchi_book.json`.
484#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
485#[derive(Debug, Clone)]
486pub struct StartupGrant {
487    pub id: String,
488    pub items: Vec<String>,
489    pub book: Option<String>,
490    pub command: Option<String>,
491}
492
493impl StartupGrant {
494    pub fn new(id: impl Into<String>) -> Self {
495        Self {
496            id: id.into(),
497            items: Vec::new(),
498            book: None,
499            command: None,
500        }
501    }
502
503    pub fn item(mut self, item_id: impl Into<String>) -> Self {
504        self.items.push(item_id.into());
505        self
506    }
507
508    pub fn book(mut self, book: impl Into<String>) -> Self {
509        self.book = Some(book.into());
510        self
511    }
512
513    pub fn command(mut self, cmd: impl Into<String>) -> Self {
514        self.command = Some(cmd.into());
515        self
516    }
517
518    pub fn to_json(&self) -> String {
519        let items: Vec<String> = self
520            .items
521            .iter()
522            .map(|i| format!("{{\"item\":\"{i}\"}}"))
523            .collect();
524        let mut entries = String::new();
525        if !items.is_empty() {
526            entries.push_str(&format!("[{}]", items.join(",")));
527        }
528        if let Some(book) = &self.book {
529            if !entries.is_empty() {
530                entries.push(',');
531            }
532            let book_entry = format!(
533                "{{\"type\":\"item\",\"name\":\"minecraft:written_book\",\"functions\":[{{\"function\":\"set_nbt\",\"tag\":\"{{yog_book:\\\"{book}\\\"}}\"}}]}}"
534            );
535            entries.push_str(&book_entry);
536        }
537        if let Some(cmd) = &self.command {
538            if !entries.is_empty() {
539                entries.push(',');
540            }
541            entries.push_str(&format!(
542                "{{\"type\":\"minecraft:command\",\"command\":\"{}\"}}",
543                cmd.replace('"', "\\\"")
544            ));
545        }
546        format!(
547            "{{\"type\":\"yog:startup_grant\",\"entries\":{},\"id\":\"{}\"}}",
548            if entries.is_empty() {
549                "[]".to_string()
550            } else {
551                entries
552            },
553            self.id
554        )
555    }
556}
557
558// ── Blocks ───────────────────────────────────────────────────────────────────
559#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
560#[derive(Debug, Clone)]
561pub struct AdvancementReward {
562    pub id: String,
563    /// The item to grant (default: special book item when book rewards).
564    pub item: String,
565    /// Optional NBT tag to apply.
566    pub nbt: Option<String>,
567    /// If set, the reward links to a yog-book.
568    pub book: Option<String>,
569}
570
571impl AdvancementReward {
572    pub fn new(id: impl Into<String>) -> Self {
573        Self {
574            id: id.into(),
575            item: "minecraft:written_book".into(),
576            nbt: None,
577            book: None,
578        }
579    }
580
581    pub fn item(mut self, item: impl Into<String>) -> Self {
582        self.item = item.into();
583        self
584    }
585
586    pub fn nbt(mut self, nbt: impl Into<String>) -> Self {
587        self.nbt = Some(nbt.into());
588        self
589    }
590
591    pub fn book(mut self, book: impl Into<String>) -> Self {
592        let book_str: String = book.into();
593        self.book = Some(book_str.clone());
594        self.nbt = Some(format!(
595            "{{yog_book:\"{book_str}\",title:\"Yog Book\",author:\"Yog\"}}"
596        ));
597        self
598    }
599
600    pub fn to_json(&self) -> String {
601        let nbt_part = self
602            .nbt
603            .as_ref()
604            .map(|n| format!("{{\"function\":\"set_nbt\",\"tag\":{n}}}"));
605        let entries = if let Some(nbt_part) = nbt_part {
606            format!(
607                "[{{\"type\":\"item\",\"name\":\"{}\",\"functions\":[{}]}}]",
608                self.item, nbt_part
609            )
610        } else {
611            format!("[{{\"type\":\"item\",\"name\":\"{}\"}}]", self.item)
612        };
613        format!(
614            "{{\"type\":\"advancement_reward\",\"pools\":[{{\"rolls\":1,\"entries\":{entries}}}]}}"
615        )
616    }
617}
618
619// ── Blocks ───────────────────────────────────────────────────────────────────
620
621/// A custom block to register; it also gets a matching block-item.
622#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
623#[derive(Debug, Clone)]
624pub struct BlockDef {
625    pub id: String,
626    pub hardness: f32,
627    pub resistance: f32,
628    pub name: Option<String>,
629    /// Optional collision/outline box in pixel units (0–16): `[x1,y1,z1,x2,y2,z2]`.
630    /// `None` = full cube.
631    pub shape: Option<[f32; 6]>,
632    /// Light emitted by this block (0 = none, 15 = max, like a torch).
633    pub light_level: u8,
634    /// Sound group id: `"stone"`, `"wood"`, `"grass"`, `"sand"`, `"snow"`,
635    /// `"gravel"`, `"metal"`, `"glass"`, `"wool"`, `"nether_brick"`.
636    /// `None` = stone (Minecraft default).
637    pub sound: Option<String>,
638    /// If `true`, the correct tool (from the block's tags) is required for drops.
639    pub requires_tool: bool,
640    /// If `true`, entities pass through this block (like flowers or torches).
641    pub no_collision: bool,
642    /// Friction coefficient. `0.0` = default (0.6). Ice = 0.989.
643    pub slipperiness: f32,
644    /// If `true`, this block dynamically grows arms toward neighbors it's
645    /// compatible with (fence/pipe-style): the Java host tracks N/S/E/W/U/D
646    /// boolean blockstate properties, recomputed on placement and on
647    /// neighbor change, and grows the collision/outline shape from the
648    /// `shape` core box (or a default post) out to each connected side.
649    pub connects: bool,
650    /// Connection compatibility tags (configured in code via
651    /// `.connect_groups(&[...])` — see that method's docs for how this
652    /// drives which blocks link up). Independent of `connects`: a block can
653    /// carry tags purely as a connection *target* (e.g. an ALU accepting a
654    /// Digital Cable) without dynamically growing its own shape.
655    pub connect_groups: Vec<String>,
656    /// Id of a `YogInventoryDef` this block is backed by — `None` = plain
657    /// block (default). See `yog_inventory`'s DESIGN.md.
658    pub inventory_id: Option<String>,
659    /// Gives off redstone power — how much, per position, the mod sets with
660    /// `Server::set_redstone_output`.
661    pub redstone_source: bool,
662}
663
664impl BlockDef {
665    pub fn new(id: impl Into<String>) -> Self {
666        Self {
667            id: id.into(),
668            hardness: 1.5,
669            resistance: 6.0,
670            name: None,
671            shape: None,
672            light_level: 0,
673            sound: None,
674            requires_tool: false,
675            no_collision: false,
676            slipperiness: 0.0,
677            connects: false,
678            connect_groups: Vec::new(),
679            inventory_id: None,
680            redstone_source: false,
681        }
682    }
683
684    /// Mining hardness and blast resistance (defaults 1.5 / 6.0).
685    pub fn strength(mut self, hardness: f32, resistance: f32) -> Self {
686        self.hardness = hardness;
687        self.resistance = resistance;
688        self
689    }
690
691    /// Display name shown in-game.
692    pub fn name(mut self, name: impl Into<String>) -> Self {
693        self.name = Some(name.into());
694        self
695    }
696
697    /// Custom hitbox/outline in pixel units (0–16).
698    pub fn shape(mut self, x1: f32, y1: f32, z1: f32, x2: f32, y2: f32, z2: f32) -> Self {
699        self.shape = Some([x1, y1, z1, x2, y2, z2]);
700        self
701    }
702
703    /// Emitted light level (0–15).
704    pub fn light_level(mut self, level: u8) -> Self {
705        self.light_level = level.min(15);
706        self
707    }
708
709    /// Sound group: `"stone"`, `"wood"`, `"grass"`, `"sand"`, `"snow"`,
710    /// `"gravel"`, `"metal"`, `"glass"`, `"wool"`, `"nether_brick"`.
711    pub fn sound(mut self, group: impl Into<String>) -> Self {
712        self.sound = Some(group.into());
713        self
714    }
715
716    /// Correct tool required for loot drops (equivalent to `requiresTool()`).
717    pub fn requires_tool(mut self) -> Self {
718        self.requires_tool = true;
719        self
720    }
721
722    /// No physical collision — entities pass through (like flowers).
723    pub fn no_collision(mut self) -> Self {
724        self.no_collision = true;
725        self
726    }
727
728    /// Friction (default 0.6). Set to 0.989 for ice-like slipperiness.
729    pub fn slipperiness(mut self, value: f32) -> Self {
730        self.slipperiness = value;
731        self
732    }
733
734    /// Dynamically grow arms toward compatible neighbors — fence/pipe-style.
735    /// "Compatible" means the neighbor also has a `connect_groups` tag in
736    /// common (set here, or via `connect_groups` alone on a static target
737    /// block). Call `.connect_groups(&[...])` too, or this connects to
738    /// nothing. See the `connects` field doc.
739    pub fn connects_to_neighbors(mut self) -> Self {
740        self.connects = true;
741        self
742    }
743
744    /// Connection compatibility tags — two blocks link up (for
745    /// `connects_to_neighbors` arm growth, and as valid connection targets
746    /// generally) when their tag sets share at least one entry. Example:
747    /// `analog_cable` and `redstone_port` both carry `"analog"`;
748    /// `digital_cable` carries `"digital"`; an ALU block carries both, so it
749    /// accepts either cable while a Redstone Port only accepts the analog one.
750    pub fn connect_groups(mut self, groups: &[&str]) -> Self {
751        self.connect_groups = groups
752            .iter()
753            .map(std::string::ToString::to_string)
754            .collect();
755        self
756    }
757
758    /// Back this block with a real Container/Menu inventory screen, whose
759    /// shape/layout was registered separately via `Registry::register_inventory`
760    /// — see `yog_inventory::InventoryDef`. `id` is that def's id.
761    /// A redstone power source: what each placed block gives off is set
762    /// with `Server::set_redstone_output` (0 until then).
763    pub fn redstone_source(mut self) -> Self {
764        self.redstone_source = true;
765        self
766    }
767
768    pub fn inventory(mut self, id: impl Into<String>) -> Self {
769        self.inventory_id = Some(id.into());
770        self
771    }
772}
773
774#[cfg(test)]
775mod recipe_tests {
776    use super::*;
777
778    #[test]
779    fn writes_the_yog_format() {
780        let shaped = Recipe::shaped("m:a", "m:b", 2).row("D ").key('D', "#c:dusts").key_any('E', ["m:x", "m:y"]).group("g");
781        assert_eq!(
782            shaped.to_json(),
783            r##"{"type":"shaped","pattern":["D "],"key":{"D":"#c:dusts","E":["m:x","m:y"]},"result":"m:b","count":2,"group":"g"}"##
784        );
785        let blast = Recipe::cooking("m:a", Cooking::Blasting, "#c:ores", "m:b", 1).experience(0.5);
786        assert_eq!(blast.to_json(), r##"{"type":"blasting","ingredient":"#c:ores","experience":0.5,"cook_time":100,"result":"m:b"}"##);
787        let smith = Recipe::smithing("m:a", "m:t", "m:base", "m:add", "m:out");
788        assert!(smith.to_json().contains(r#""type":"smithing","template":"m:t""#));
789    }
790}