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/// What a `.yog` registers when the game starts — content the game can't
17/// add or change while it runs (blocks, items, entity types, sound groups,
18/// materials, dimensions): a line each, `<kind> <id> <hash>`, the hash of
19/// what can't change. A hot reload compares the new `.yog`'s with what's
20/// registered ([`registration_changes`]).
21pub const REGISTRATIONS_FILE: &str = "registrations.txt";
22
23/// How `new` (a [`REGISTRATIONS_FILE`]) differs from `registered`: what it
24/// adds, removes and changes — `<kind> <id>` each.
25pub fn registration_changes(
26    registered: &str,
27    new: &str,
28) -> (Vec<String>, Vec<String>, Vec<String>) {
29    fn read(text: &str) -> std::collections::BTreeMap<String, String> {
30        text.lines()
31            .filter_map(|l| {
32                let (key, hash) = l.trim().rsplit_once(' ')?;
33                Some((key.to_string(), hash.to_string()))
34            })
35            .collect()
36    }
37    let (old, new) = (read(registered), read(new));
38    let added = new
39        .keys()
40        .filter(|k| !old.contains_key(*k))
41        .cloned()
42        .collect();
43    let removed = old
44        .keys()
45        .filter(|k| !new.contains_key(*k))
46        .cloned()
47        .collect();
48    let changed = new
49        .iter()
50        .filter(|(k, h)| old.get(*k).is_some_and(|o| o != *h))
51        .map(|(k, _)| k.clone())
52        .collect();
53    (added, removed, changed)
54}
55mod crafting;
56mod held;
57mod looks;
58mod world;
59
60pub use crafting::loot::{Loot, LootAdd, LootModifier, LootScale, LootStack};
61pub use crafting::{loot, recipe_kinds};
62pub use held::effects::{
63    AttributeDef, Brew, EffectCategory, EffectDef, EffectModifier, ModifierOperation, PotionDef,
64    PotionEffect,
65};
66pub use held::item_looks::{ItemLooks, ItemTint, LookModels};
67pub use held::tool_levels::{LevelTag, ToolLadder, ToolLevel};
68pub use held::{effects, item_looks, tool_levels};
69pub use looks::atlases::Atlas;
70pub use looks::particles::{Blend, Frames, Lifetime, Particle, ParticleDef, ParticleRender};
71pub use looks::videos::{Clock, Plan, VideoDef};
72pub use looks::{atlases, particles, videos};
73
74pub use crafting::recipes::{COOK_EXPERIENCE, Cooking, Recipe, RecipeKind, StartupGrant};
75pub use held::equipment::{
76    ArmorDefense, ArmorMaterial, ArmorPiece, ArmorSlot, DIGGERS, GameTool, REFERENCE_KIND,
77    REFERENCE_MATERIAL, TOOL_KINDS, ToolItem, ToolMaterial,
78};
79pub use held::items::{FoodDef, Hangs, ItemDef};
80pub use world::blocks::{BlockDef, BlockProperty, Facing, Placement};
81pub use world::entities::{EntityDef, EntityKind};
82pub use world::sounds::SoundGroup;
83pub use world::worldgen::BiomeFeature;
84
85#[cfg(test)]
86mod registration_tests {
87    #[test]
88    fn a_hot_reload_sees_what_registration_changed() {
89        let old = "block mymod:a 11\nblock mymod:b 22\nitem mymod:c 33\n";
90        let new = "block mymod:a 11\nblock mymod:b 99\nitem mymod:d 44\n";
91        let (added, removed, changed) = super::registration_changes(old, new);
92        assert_eq!(added, ["item mymod:d"]);
93        assert_eq!(removed, ["item mymod:c"]);
94        assert_eq!(changed, ["block mymod:b"]);
95        assert_eq!(
96            super::registration_changes(old, old),
97            (vec![], vec![], vec![])
98        );
99    }
100}
101
102#[cfg(test)]
103mod material_tests {
104    use super::*;
105
106    #[test]
107    fn the_games_materials_are_built_in() {
108        assert_eq!(ToolMaterial::built_ins().len(), 6);
109        let iron = ToolMaterial::built_in("iron").unwrap();
110        assert_eq!(
111            (iron.durability, iron.level_index, iron.incorrect.as_str()),
112            (250, 2, "minecraft:incorrect_for_iron_tool")
113        );
114        assert_eq!(
115            ArmorMaterial::built_in("minecraft:netherite")
116                .unwrap()
117                .knockback_resistance,
118            0.1
119        );
120        assert_eq!(ArmorMaterial::built_in("diamond").unwrap().defense.chest, 8);
121        let mythril = ToolMaterial::like("mymod:mythril", "diamond").unwrap();
122        assert_eq!(
123            (mythril.id.as_str(), mythril.durability),
124            ("mymod:mythril", 1561)
125        );
126    }
127}
128
129#[cfg(test)]
130mod sound_tests {
131    use super::*;
132
133    #[test]
134    fn the_games_sound_groups_are_built_in() {
135        assert!(SoundGroup::built_ins().len() > 100);
136        let stone = SoundGroup::built_in("stone").unwrap();
137        assert_eq!(
138            (stone.break_sound.as_str(), stone.volume),
139            ("minecraft:block.stone.break", 1.0)
140        );
141        assert_eq!(SoundGroup::built_in("minecraft:metal").unwrap().pitch, 1.5);
142        assert!(SoundGroup::built_in("mymod:crystal").is_none());
143    }
144
145    #[test]
146    fn a_mods_group_crosses_as_yog_data() {
147        use yog_data::Schema;
148        let g = SoundGroup::new("mymod:crystal").pitch(1.2);
149        assert_eq!(g.fall, "mymod:crystal.fall");
150        let back = yog_data::from_value::<SoundGroup>(&g.write())
151            .unwrap()
152            .value;
153        assert_eq!(back, g);
154        assert_eq!(
155            g.write().get("break").and_then(|v| v.as_str()),
156            Some("mymod:crystal.break")
157        );
158    }
159}
160
161#[cfg(test)]
162impl Recipe {
163    fn to_json(&self) -> String {
164        yog_data::json::to_json(&self.to_data()).to_string()
165    }
166}
167
168#[cfg(test)]
169mod recipe_tests {
170    use super::*;
171
172    #[test]
173    fn writes_the_yog_format() {
174        let js = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
175        let shaped = Recipe::shaped("m:a", "m:b", 2)
176            .row("D ")
177            .key('D', "#c:dusts")
178            .key_any('E', ["m:x", "m:y"])
179            .group("g");
180        assert_eq!(
181            js(&shaped.to_json()),
182            js(
183                r##"{"type":"shaped","pattern":["D "],"key":{"D":"#c:dusts","E":["m:x","m:y"]},"result":"m:b","count":2,"group":"g"}"##
184            )
185        );
186        assert_eq!(
187            shaped.to_data().as_map().unwrap().keys().next(),
188            Some("type"),
189            "the type first"
190        );
191        let blast = Recipe::cooking("m:a", Cooking::Blasting, "#c:ores", "m:b", 1).experience(0.7);
192        assert_eq!(
193            js(&blast.to_json()),
194            js(
195                r##"{"type":"blasting","ingredient":"#c:ores","experience":0.7,"cook_time":100,"result":"m:b"}"##
196            )
197        );
198        let smith = Recipe::smithing("m:a", "m:t", "m:base", "m:add", "m:out");
199        assert_eq!(js(&smith.to_json())["template"], "m:t");
200    }
201}
202
203#[cfg(test)]
204mod json_tests {
205    use super::*;
206
207    /// Every `to_json` makes valid JSON, whatever the strings hold.
208    #[test]
209    fn json_survives_any_string() {
210        let odd = "m:a\"b\\c\nd\te\u{1}";
211        let parse = |j: String| {
212            serde_json::from_str::<serde_json::Value>(&j).unwrap_or_else(|e| panic!("{e}: {j}"))
213        };
214        for r in [
215            Recipe::shaped(odd, odd, 1)
216                .row("A")
217                .key('A', odd)
218                .group(odd)
219                .category(odd),
220            Recipe::shapeless(odd, odd, 3)
221                .ingredient(odd)
222                .ingredient_any([odd, "m:x"]),
223            Recipe::cooking(odd, Cooking::Smoking, odd, odd, 1).cook_time(5),
224            Recipe::stonecutting(odd, odd, odd, 2),
225            Recipe::smithing(odd, odd, odd, odd, odd),
226        ] {
227            let v = parse(r.to_json());
228            assert_eq!(v["result"], odd, "the string comes back as it was");
229        }
230    }
231
232    #[test]
233    fn recipes_and_grants_name_their_parts() {
234        let r = Recipe::cooking(
235            "mymod:a",
236            Cooking::Campfire,
237            "minecraft:beef",
238            "minecraft:cooked_beef",
239            1,
240        );
241        let v: serde_json::Value = serde_json::from_str(&r.to_json()).unwrap();
242        assert_eq!(v["type"], "campfire_cooking");
243        assert_eq!(v["cook_time"], 600, "the method's default time");
244        assert_eq!(r.ns_name(), ("mymod", "a"));
245        assert_eq!(Recipe::shaped("plain", "x", 1).ns_name().1, "plain");
246    }
247}
248
249/// The tables yog-registry ships read: none falls back to empty in the game.
250#[cfg(test)]
251mod built_in_tests {
252    use super::*;
253
254    #[test]
255    fn every_built_in_table_reads() {
256        let reads = |name: &str, ok: bool| assert!(ok, "{name} won't read");
257        reads(
258            "tool materials",
259            yog_data::from_text::<Vec<ToolMaterial>>(include_str!(
260                "../assets/tool_materials.ydata"
261            ))
262            .is_ok(),
263        );
264        reads(
265            "armor materials",
266            yog_data::from_text::<Vec<ArmorMaterial>>(include_str!(
267                "../assets/armor_materials.ydata"
268            ))
269            .is_ok(),
270        );
271        reads(
272            "sound groups",
273            yog_data::from_text::<Vec<SoundGroup>>(include_str!("../assets/sound_groups.ydata"))
274                .is_ok(),
275        );
276        reads(
277            "item looks",
278            yog_data::from_text::<item_looks::ItemLooks>(include_str!(
279                "../assets/item_looks.ydata"
280            ))
281            .is_ok(),
282        );
283        reads(
284            "tool levels",
285            yog_data::from_text::<tool_levels::ToolLadder>(include_str!(
286                "../assets/tool_levels.ydata"
287            ))
288            .is_ok(),
289        );
290        reads(
291            "atlases",
292            yog_data::from_text::<Vec<atlases::Atlas>>(include_str!("../assets/atlases.ydata"))
293                .is_ok(),
294        );
295        reads(
296            "recipe kinds",
297            yog_data::from_text::<Vec<recipe_kinds::RecipeKindDef>>(include_str!(
298                "../data/recipe_kinds.ydata"
299            ))
300            .is_ok(),
301        );
302    }
303}