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