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
144fn item_list_json(items: &[String]) -> String {
145    items
146        .iter()
147        .map(|i| ingredient_json(i))
148        .collect::<Vec<_>>()
149        .join(",")
150}
151
152/// A shaped crafting recipe (3×3 grid with a pattern and key mapping).
153///
154/// ```
155/// # use yog_registry::ShapedRecipe;
156/// ShapedRecipe::new("yog:ruby_sword", "yog:ruby_shard", 1)
157///     .row("R  ").row("RS ").row(" S ")
158///     .key('R', "yog:ruby_shard").key('S', "minecraft:stick");
159/// ```
160#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
161#[derive(Debug, Clone)]
162pub struct ShapedRecipe {
163    pub id: String,
164    pub output: String,
165    pub count: u32,
166    rows: Vec<String>,
167    keys: Vec<(char, String)>,
168}
169
170impl ShapedRecipe {
171    pub fn new(id: impl Into<String>, output: impl Into<String>, count: u32) -> Self {
172        Self {
173            id: id.into(),
174            output: output.into(),
175            count,
176            rows: Vec::new(),
177            keys: Vec::new(),
178        }
179    }
180
181    pub fn row(mut self, pattern: impl Into<String>) -> Self {
182        self.rows.push(pattern.into());
183        self
184    }
185
186    /// What `symbol` stands for: an item id, or `#ns:tag` for an item tag.
187    pub fn key(mut self, symbol: char, item_id: impl Into<String>) -> Self {
188        self.keys.push((symbol, item_id.into()));
189        self
190    }
191
192    /// Generate the Minecraft 1.20 recipe JSON for this recipe.
193    pub fn to_json(&self) -> String {
194        let pattern: String = self
195            .rows
196            .iter()
197            .map(|r| format!("\"{r}\""))
198            .collect::<Vec<_>>()
199            .join(",");
200        let keys: String = self
201            .keys
202            .iter()
203            .map(|(ch, item)| format!("\"{ch}\":{}", ingredient_json(item)))
204            .collect::<Vec<_>>()
205            .join(",");
206        format!(
207            "{{\"type\":\"minecraft:crafting_shaped\",\"pattern\":[{}],\"key\":{{{}}},\"result\":{{\"item\":\"{}\",\"count\":{}}}}}",
208            pattern, keys, self.output, self.count
209        )
210    }
211
212    /// Split `namespace:name` from the recipe id.
213    pub fn ns_name(&self) -> (&str, &str) {
214        split_id(&self.id, VANILLA_NS)
215    }
216}
217
218/// A shapeless crafting recipe (unordered ingredients).
219#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
220#[derive(Debug, Clone)]
221pub struct ShapelessRecipe {
222    pub id: String,
223    pub output: String,
224    pub count: u32,
225    pub ingredients: Vec<String>,
226}
227
228impl ShapelessRecipe {
229    pub fn new(id: impl Into<String>, output: impl Into<String>, count: u32) -> Self {
230        Self {
231            id: id.into(),
232            output: output.into(),
233            count,
234            ingredients: Vec::new(),
235        }
236    }
237
238    /// An item id, or `#ns:tag` for an item tag.
239    pub fn ingredient(mut self, item_id: impl Into<String>) -> Self {
240        self.ingredients.push(item_id.into());
241        self
242    }
243
244    pub fn to_json(&self) -> String {
245        let ingr = item_list_json(&self.ingredients);
246        format!(
247            "{{\"type\":\"minecraft:crafting_shapeless\",\"ingredients\":[{}],\"result\":{{\"item\":\"{}\",\"count\":{}}}}}",
248            ingr, self.output, self.count
249        )
250    }
251
252    pub fn ns_name(&self) -> (&str, &str) {
253        split_id(&self.id, VANILLA_NS)
254    }
255}
256
257/// A furnace smelting recipe.
258#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
259#[derive(Debug, Clone)]
260pub struct FurnaceRecipe {
261    pub id: String,
262    pub input: String,
263    pub output: String,
264    pub count: u32,
265    pub experience: f32,
266    pub cook_time: u32,
267}
268
269impl FurnaceRecipe {
270    pub fn new(
271        id: impl Into<String>,
272        input: impl Into<String>,
273        output: impl Into<String>,
274        count: u32,
275    ) -> Self {
276        Self {
277            id: id.into(),
278            input: input.into(),
279            output: output.into(),
280            count,
281            experience: 0.1,
282            cook_time: 200,
283        }
284    }
285
286    pub fn experience(mut self, xp: f32) -> Self {
287        self.experience = xp;
288        self
289    }
290
291    /// Cooking time in ticks (default 200 = 10 seconds).
292    pub fn cook_time(mut self, ticks: u32) -> Self {
293        self.cook_time = ticks;
294        self
295    }
296
297    pub fn to_json(&self) -> String {
298        format!(
299            "{{\"type\":\"minecraft:smelting\",\"ingredient\":{},\"result\":\"{}\",\"experience\":{},\"cookingtime\":{}}}",
300            ingredient_json(&self.input), self.output, self.experience, self.cook_time
301        )
302    }
303
304    pub fn ns_name(&self) -> (&str, &str) {
305        split_id(&self.id, VANILLA_NS)
306    }
307}
308
309// ── BookRecipe (like Patchouli's shapeless_book_recipe) ──────────────────────
310
311/// A shapeless recipe that produces a book from `yog-book`.
312/// Replaces `patchouli:shapeless_book_recipe`.
313#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
314#[derive(Debug, Clone)]
315pub struct BookRecipe {
316    pub id: String,
317    pub book: String,
318    pub ingredients: Vec<String>,
319}
320
321impl BookRecipe {
322    pub fn new(id: impl Into<String>, book: impl Into<String>) -> Self {
323        Self {
324            id: id.into(),
325            book: book.into(),
326            ingredients: Vec::new(),
327        }
328    }
329
330    pub fn ingredient(mut self, item_id: impl Into<String>) -> Self {
331        self.ingredients.push(item_id.into());
332        self
333    }
334
335    pub fn to_json(&self) -> String {
336        let ingr = item_list_json(&self.ingredients);
337        format!(
338            "{{\"type\":\"yog:crafting_book\",\"ingredients\":[{}],\"book\":\"{}\"}}",
339            ingr, self.book
340        )
341    }
342
343    pub fn ns_name(&self) -> (&str, &str) {
344        split_id(&self.id, YOG_NS)
345    }
346}
347
348// ── ItemModifier ─────────────────────────────────────────────────────────────
349
350/// An item modifier applied during loot generation (like smelting, enchanted, etc.).
351#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
352#[derive(Debug, Clone)]
353pub struct ItemModifier {
354    pub id: String,
355    /// Modifier function identifier, e.g. "yog:set_count" or "example:shard_reducer".
356    pub function: String,
357    /// Parameters as JSON object.
358    pub parameters: std::collections::HashMap<String, String>,
359}
360
361impl ItemModifier {
362    pub fn new(id: impl Into<String>, function: impl Into<String>) -> Self {
363        Self {
364            id: id.into(),
365            function: function.into(),
366            parameters: std::collections::HashMap::new(),
367        }
368    }
369
370    pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
371        self.parameters.insert(key.into(), value.into());
372        self
373    }
374
375    pub fn to_json(&self) -> String {
376        let params: String = self
377            .parameters
378            .iter()
379            .map(|(k, v)| format!("\"{k}\":{v}"))
380            .collect::<Vec<_>>()
381            .join(",");
382        format!(
383            "{{\"function\":\"{}\",{}}}",
384            self.function,
385            if params.is_empty() {
386                String::new()
387            } else {
388                params
389            }
390        )
391    }
392}
393
394// ── StartupGrant ─────────────────────────────────────────────────────────────
395
396/// Grant items/books to every player once when they first join.
397/// This is the Yog-side replacement for `grant_patchi_book.json`.
398#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
399#[derive(Debug, Clone)]
400pub struct StartupGrant {
401    pub id: String,
402    pub items: Vec<String>,
403    pub book: Option<String>,
404    pub command: Option<String>,
405}
406
407impl StartupGrant {
408    pub fn new(id: impl Into<String>) -> Self {
409        Self {
410            id: id.into(),
411            items: Vec::new(),
412            book: None,
413            command: None,
414        }
415    }
416
417    pub fn item(mut self, item_id: impl Into<String>) -> Self {
418        self.items.push(item_id.into());
419        self
420    }
421
422    pub fn book(mut self, book: impl Into<String>) -> Self {
423        self.book = Some(book.into());
424        self
425    }
426
427    pub fn command(mut self, cmd: impl Into<String>) -> Self {
428        self.command = Some(cmd.into());
429        self
430    }
431
432    pub fn to_json(&self) -> String {
433        let items: Vec<String> = self
434            .items
435            .iter()
436            .map(|i| format!("{{\"item\":\"{i}\"}}"))
437            .collect();
438        let mut entries = String::new();
439        if !items.is_empty() {
440            entries.push_str(&format!("[{}]", items.join(",")));
441        }
442        if let Some(book) = &self.book {
443            if !entries.is_empty() {
444                entries.push(',');
445            }
446            let book_entry = format!(
447                "{{\"type\":\"item\",\"name\":\"minecraft:written_book\",\"functions\":[{{\"function\":\"set_nbt\",\"tag\":\"{{yog_book:\\\"{book}\\\"}}\"}}]}}"
448            );
449            entries.push_str(&book_entry);
450        }
451        if let Some(cmd) = &self.command {
452            if !entries.is_empty() {
453                entries.push(',');
454            }
455            entries.push_str(&format!(
456                "{{\"type\":\"minecraft:command\",\"command\":\"{}\"}}",
457                cmd.replace('"', "\\\"")
458            ));
459        }
460        format!(
461            "{{\"type\":\"yog:startup_grant\",\"entries\":{},\"id\":\"{}\"}}",
462            if entries.is_empty() {
463                "[]".to_string()
464            } else {
465                entries
466            },
467            self.id
468        )
469    }
470}
471
472// ── Blocks ───────────────────────────────────────────────────────────────────
473#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
474#[derive(Debug, Clone)]
475pub struct AdvancementReward {
476    pub id: String,
477    /// The item to grant (default: special book item when book rewards).
478    pub item: String,
479    /// Optional NBT tag to apply.
480    pub nbt: Option<String>,
481    /// If set, the reward links to a yog-book.
482    pub book: Option<String>,
483}
484
485impl AdvancementReward {
486    pub fn new(id: impl Into<String>) -> Self {
487        Self {
488            id: id.into(),
489            item: "minecraft:written_book".into(),
490            nbt: None,
491            book: None,
492        }
493    }
494
495    pub fn item(mut self, item: impl Into<String>) -> Self {
496        self.item = item.into();
497        self
498    }
499
500    pub fn nbt(mut self, nbt: impl Into<String>) -> Self {
501        self.nbt = Some(nbt.into());
502        self
503    }
504
505    pub fn book(mut self, book: impl Into<String>) -> Self {
506        let book_str: String = book.into();
507        self.book = Some(book_str.clone());
508        self.nbt = Some(format!(
509            "{{yog_book:\"{book_str}\",title:\"Yog Book\",author:\"Yog\"}}"
510        ));
511        self
512    }
513
514    pub fn to_json(&self) -> String {
515        let nbt_part = self
516            .nbt
517            .as_ref()
518            .map(|n| format!("{{\"function\":\"set_nbt\",\"tag\":{n}}}"));
519        let entries = if let Some(nbt_part) = nbt_part {
520            format!(
521                "[{{\"type\":\"item\",\"name\":\"{}\",\"functions\":[{}]}}]",
522                self.item, nbt_part
523            )
524        } else {
525            format!("[{{\"type\":\"item\",\"name\":\"{}\"}}]", self.item)
526        };
527        format!(
528            "{{\"type\":\"advancement_reward\",\"pools\":[{{\"rolls\":1,\"entries\":{entries}}}]}}"
529        )
530    }
531}
532
533// ── Blocks ───────────────────────────────────────────────────────────────────
534
535/// A custom block to register; it also gets a matching block-item.
536#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
537#[derive(Debug, Clone)]
538pub struct BlockDef {
539    pub id: String,
540    pub hardness: f32,
541    pub resistance: f32,
542    pub name: Option<String>,
543    /// Optional collision/outline box in pixel units (0–16): `[x1,y1,z1,x2,y2,z2]`.
544    /// `None` = full cube.
545    pub shape: Option<[f32; 6]>,
546    /// Light emitted by this block (0 = none, 15 = max, like a torch).
547    pub light_level: u8,
548    /// Sound group id: `"stone"`, `"wood"`, `"grass"`, `"sand"`, `"snow"`,
549    /// `"gravel"`, `"metal"`, `"glass"`, `"wool"`, `"nether_brick"`.
550    /// `None` = stone (Minecraft default).
551    pub sound: Option<String>,
552    /// If `true`, the correct tool (from the block's tags) is required for drops.
553    pub requires_tool: bool,
554    /// If `true`, entities pass through this block (like flowers or torches).
555    pub no_collision: bool,
556    /// Friction coefficient. `0.0` = default (0.6). Ice = 0.989.
557    pub slipperiness: f32,
558    /// If `true`, this block dynamically grows arms toward neighbors it's
559    /// compatible with (fence/pipe-style): the Java host tracks N/S/E/W/U/D
560    /// boolean blockstate properties, recomputed on placement and on
561    /// neighbor change, and grows the collision/outline shape from the
562    /// `shape` core box (or a default post) out to each connected side.
563    pub connects: bool,
564    /// Connection compatibility tags (configured in code via
565    /// `.connect_groups(&[...])` — see that method's docs for how this
566    /// drives which blocks link up). Independent of `connects`: a block can
567    /// carry tags purely as a connection *target* (e.g. an ALU accepting a
568    /// Digital Cable) without dynamically growing its own shape.
569    pub connect_groups: Vec<String>,
570    /// Id of a `YogInventoryDef` this block is backed by — `None` = plain
571    /// block (default). See `yog_inventory`'s DESIGN.md.
572    pub inventory_id: Option<String>,
573}
574
575impl BlockDef {
576    pub fn new(id: impl Into<String>) -> Self {
577        Self {
578            id: id.into(),
579            hardness: 1.5,
580            resistance: 6.0,
581            name: None,
582            shape: None,
583            light_level: 0,
584            sound: None,
585            requires_tool: false,
586            no_collision: false,
587            slipperiness: 0.0,
588            connects: false,
589            connect_groups: Vec::new(),
590            inventory_id: None,
591        }
592    }
593
594    /// Mining hardness and blast resistance (defaults 1.5 / 6.0).
595    pub fn strength(mut self, hardness: f32, resistance: f32) -> Self {
596        self.hardness = hardness;
597        self.resistance = resistance;
598        self
599    }
600
601    /// Display name shown in-game.
602    pub fn name(mut self, name: impl Into<String>) -> Self {
603        self.name = Some(name.into());
604        self
605    }
606
607    /// Custom hitbox/outline in pixel units (0–16).
608    pub fn shape(mut self, x1: f32, y1: f32, z1: f32, x2: f32, y2: f32, z2: f32) -> Self {
609        self.shape = Some([x1, y1, z1, x2, y2, z2]);
610        self
611    }
612
613    /// Emitted light level (0–15).
614    pub fn light_level(mut self, level: u8) -> Self {
615        self.light_level = level.min(15);
616        self
617    }
618
619    /// Sound group: `"stone"`, `"wood"`, `"grass"`, `"sand"`, `"snow"`,
620    /// `"gravel"`, `"metal"`, `"glass"`, `"wool"`, `"nether_brick"`.
621    pub fn sound(mut self, group: impl Into<String>) -> Self {
622        self.sound = Some(group.into());
623        self
624    }
625
626    /// Correct tool required for loot drops (equivalent to `requiresTool()`).
627    pub fn requires_tool(mut self) -> Self {
628        self.requires_tool = true;
629        self
630    }
631
632    /// No physical collision — entities pass through (like flowers).
633    pub fn no_collision(mut self) -> Self {
634        self.no_collision = true;
635        self
636    }
637
638    /// Friction (default 0.6). Set to 0.989 for ice-like slipperiness.
639    pub fn slipperiness(mut self, value: f32) -> Self {
640        self.slipperiness = value;
641        self
642    }
643
644    /// Dynamically grow arms toward compatible neighbors — fence/pipe-style.
645    /// "Compatible" means the neighbor also has a `connect_groups` tag in
646    /// common (set here, or via `connect_groups` alone on a static target
647    /// block). Call `.connect_groups(&[...])` too, or this connects to
648    /// nothing. See the `connects` field doc.
649    pub fn connects_to_neighbors(mut self) -> Self {
650        self.connects = true;
651        self
652    }
653
654    /// Connection compatibility tags — two blocks link up (for
655    /// `connects_to_neighbors` arm growth, and as valid connection targets
656    /// generally) when their tag sets share at least one entry. Example:
657    /// `analog_cable` and `redstone_port` both carry `"analog"`;
658    /// `digital_cable` carries `"digital"`; an ALU block carries both, so it
659    /// accepts either cable while a Redstone Port only accepts the analog one.
660    pub fn connect_groups(mut self, groups: &[&str]) -> Self {
661        self.connect_groups = groups
662            .iter()
663            .map(std::string::ToString::to_string)
664            .collect();
665        self
666    }
667
668    /// Back this block with a real Container/Menu inventory screen, whose
669    /// shape/layout was registered separately via `Registry::register_inventory`
670    /// — see `yog_inventory::InventoryDef`. `id` is that def's id.
671    pub fn inventory(mut self, id: impl Into<String>) -> Self {
672        self.inventory_id = Some(id.into());
673        self
674    }
675}
676
677#[cfg(test)]
678mod recipe_tests {
679    use super::*;
680
681    #[test]
682    fn tags_are_tag_ingredients() {
683        let shaped = ShapedRecipe::new("m:a", "m:b", 1).row("D").key('D', "#c:dusts").to_json();
684        assert!(shaped.contains(r#""D":{"tag":"c:dusts"}"#), "{shaped}");
685        let shapeless = ShapelessRecipe::new("m:a", "m:b", 1).ingredient("minecraft:paper").ingredient("#minecraft:logs").to_json();
686        assert!(shapeless.contains(r#"[{"item":"minecraft:paper"},{"tag":"minecraft:logs"}]"#), "{shapeless}");
687        let smelt = FurnaceRecipe::new("m:a", "#c:ores", "m:b", 1).to_json();
688        assert!(smelt.contains(r#""ingredient":{"tag":"c:ores"}"#), "{smelt}");
689    }
690}