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