Skip to main content

delvewright_dsl/
registry.rs

1//! Registries for data that lives outside the DSL: the vanilla item registry and
2//! prefab anchor metadata.
3//!
4//! v0 ships small vendored lists covering only what the M1 hello-world fixtures
5//! use. The **full** 1.21.11 item registry and the real prefab anchor metadata
6//! are vendored/loaded by the compiler (spec-0002 / ADR-0004); the compiler
7//! injects them via [`crate::validate::validate_campaign_with`].
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use crate::ids::{PoolId, PrefabId};
15
16/// Membership test for vanilla item ids (`minecraft:iron_sword`, …).
17pub trait ItemRegistry {
18    /// True if `item_id` is a known item in the pinned MC version.
19    fn contains(&self, item_id: &str) -> bool;
20
21    /// The item's `minecraft:max_stack_size` in the pinned MC version, if this
22    /// registry carries stack-size data for it.
23    ///
24    /// Needed because a single-slot fill (`item replace block … container.<n> with
25    /// <item> <count>`) fails **silently** above the cap — rabbit stew caps at 1, so
26    /// `count: 2` puts nothing in the chest (`DW0436`). `None` means "this registry
27    /// does not know", and the check is then skipped rather than guessed: the small
28    /// vendored DSL-side subset carries ids only, while the compiler injects the full
29    /// 1.21.11 table (`crates/delvec/data/item-stack-sizes-1.21.11.json`).
30    fn max_stack_size(&self, _item_id: &str) -> Option<u32> {
31        None
32    }
33
34    /// The item's `minecraft:equippable` facts in the pinned MC version
35    /// (spec-0067), which the equipment fit rule (`DW0898`) reads.
36    ///
37    /// [`crate::equipment::EquippableFact::Unknown`] means "this registry does
38    /// not know", and the fit rule then judges nothing rather than guessing:
39    /// the small vendored DSL-side subset carries ids only, while the compiler
40    /// injects the full 1.21.11 table
41    /// (`crates/delvec/data/item-equippable-1.21.11.json`).
42    fn equippable(&self, _item_id: &str) -> crate::equipment::EquippableFact<'_> {
43        crate::equipment::EquippableFact::Unknown
44    }
45}
46
47/// Membership test for vanilla entity ids (`minecraft:zombie`, …), used to
48/// validate stage-5 wave mobs (DSL v0.3). Like [`ItemRegistry`], the crate ships
49/// a small vendored subset; the compiler injects the full 1.21.11 entity registry
50/// via [`crate::validate::validate_campaign_with`].
51pub trait EntityRegistry {
52    /// True if `entity_id` is a known entity in the pinned MC version.
53    fn contains(&self, entity_id: &str) -> bool;
54}
55
56/// The vendored `entity_type` **tag** table: tag id → its member entity ids,
57/// from Mojang's own generated reports (`crates/dsl/data/entity-tags-1.21.11.json`,
58/// regenerated per MC pin by `tools/maintenance/extract-entity-tags.py`;
59/// `crates/delvec/data/PROVENANCE.md`).
60///
61/// Vanilla's answer to every question of the form "which entity types do X" that
62/// vanilla itself answers. It lives in this crate — not beside the compiler's
63/// other vendored tables — because both validation tiers ask such questions:
64/// `DW0382` (which bodies honour patrol NBT) is decided from the declaration
65/// alone and therefore here, while `DW0496` (which bodies burn in daylight) and
66/// `DW0452`/`DW0453` (which bodies are aquatic) need the assembled world and are
67/// decided in the compiler, which re-exports these three functions unchanged. A
68/// second copy is how a hand table gets started, and a hand table is how the
69/// engine comes to disagree with the game.
70pub fn entity_tags() -> &'static BTreeMap<String, BTreeSet<String>> {
71    static TAGS: std::sync::LazyLock<BTreeMap<String, BTreeSet<String>>> =
72        std::sync::LazyLock::new(|| {
73            let raw = include_str!("../data/entity-tags-1.21.11.json");
74            let parsed: BTreeMap<String, Vec<String>> =
75                serde_json::from_str(raw).expect("vendored entity-type tags are valid JSON");
76            parsed
77                .into_iter()
78                .map(|(k, v)| (k, v.into_iter().collect()))
79                .collect()
80        });
81    &TAGS
82}
83
84/// Normalize a DSL entity id to its namespaced form (`zombie` →
85/// `minecraft:zombie`), which is how the vendored tags spell every member.
86pub fn namespaced_entity(entity: &str) -> String {
87    if entity.contains(':') {
88        entity.to_string()
89    } else {
90        format!("minecraft:{entity}")
91    }
92}
93
94/// The members of the vanilla `entity_type` tag `tag`, in registry order, bare
95/// (`minecraft:pillager` → `pillager`) — what a diagnostic prints when it has to
96/// tell an author which species a rule admits.
97///
98/// Empty for a tag the pinned game does not carry, which is the same "not a
99/// member" reading [`entity_in_tag`] takes.
100pub fn entity_tag_members_bare(tag: &str) -> Vec<&'static str> {
101    entity_tags()
102        .get(tag)
103        .into_iter()
104        .flatten()
105        .map(|id| id.strip_prefix("minecraft:").unwrap_or(id))
106        .collect()
107}
108
109/// Whether `entity` is a member of the vanilla `entity_type` tag `tag`.
110///
111/// Membership only — this deliberately does **not** expand a nested `#tag`
112/// reference (a tag's member list may name another tag). Every caller so far
113/// reads a flat tag, and silently under-expanding would be the dangerous
114/// direction for a proof that grants exemptions, so the narrowing is stated
115/// rather than assumed: a nested reference reads as "not a member", which puts
116/// the entity in the *checked* class.
117pub fn entity_in_tag(entity: &str, tag: &str) -> bool {
118    let id = namespaced_entity(entity);
119    entity_tags()
120        .get(tag)
121        .is_some_and(|members| members.contains(&id))
122}
123
124/// The entity types the pinned game **discards while the world is peaceful**,
125/// namespaced (`crates/dsl/data/peaceful-despawn-1.21.11.json`, regenerated per
126/// MC pin by `tools/maintenance/extract-peaceful-despawn.py`;
127/// `crates/delvec/data/PROVENANCE.md`).
128///
129/// Read from the 1.21.11 server jar, never written by hand. Every ticked entity
130/// runs `Entity#checkDespawn()`; the `Mob` and `WitherBoss` implementations
131/// discard the body when the difficulty is peaceful and its type's
132/// `EntityType#isAllowedInPeaceful()` is false (`EntityType.Builder#notInPeaceful()`
133/// clears it), `ShulkerBullet`'s discards it on peaceful unconditionally, and
134/// `Entity`'s and `EnderDragon`'s never do. `NoAI`, `PersistenceRequired` and a
135/// `/summon` origin are not consulted. The extractor proves that list of five
136/// implementations is the whole list, and cross-checks the flag read at runtime
137/// against the `notInPeaceful()` calls in `EntityType`'s static initialiser.
138pub fn peaceful_despawn() -> &'static BTreeSet<String> {
139    static SET: std::sync::LazyLock<BTreeSet<String>> = std::sync::LazyLock::new(|| {
140        let raw = include_str!("../data/peaceful-despawn-1.21.11.json");
141        let ids: Vec<String> =
142            serde_json::from_str(raw).expect("vendored peaceful-despawn table is valid JSON");
143        ids.into_iter().collect()
144    });
145    &SET
146}
147
148/// Whether the pinned game discards a body of type `entity` while the world is
149/// peaceful — membership of [`peaceful_despawn`]. Accepts a bare or namespaced id.
150pub fn removed_on_peaceful(entity: &str) -> bool {
151    peaceful_despawn().contains(&namespaced_entity(entity))
152}
153
154/// A prefab lighting profile (spec-0001 "Lighting contract"). `lit` = floor
155/// light ≥ 7; `dim` = 3–6 (needs a rationale); `dark` = < 3 (valid only where
156/// analysis proves a night-vision mitigation — the compiler's `DW0210` check).
157///
158/// The first three are **measurements**, taken by the live probe loop. The
159/// fourth, [`LightingProfile::Unmeasured`], is the honest absence of one.
160#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(rename_all = "lowercase")]
162pub enum LightingProfile {
163    /// Floor light ≥ 7 (default requirement).
164    Lit,
165    /// Floor light 3–6, with an atmosphere rationale.
166    Dim,
167    /// Floor light < 3, only usable with a proven mitigation.
168    Dark,
169    /// The piece has never been probed, and says so.
170    ///
171    /// A generated prefab (spec-0027's grammar back end) knows where it put
172    /// blocks and nothing about the light they end up in, so it declares this
173    /// rather than fabricating a number. It is *not* a synonym for "no
174    /// `lighting` block at all": absence means legacy metadata that predates the
175    /// field, while this is a positive statement that the measurement is owed.
176    /// An unmeasured piece carries no measurement fields — a claim and its
177    /// absence cannot both be true — and is treated as not-`lit` everywhere a
178    /// profile is consumed.
179    Unmeasured,
180}
181
182impl LightingProfile {
183    /// True for the three profiles that assert a measured light level.
184    pub fn is_measurement(self) -> bool {
185        !matches!(self, LightingProfile::Unmeasured)
186    }
187}
188
189/// A prefab's declared `lighting` metadata block (measured once at library
190/// admission). Field names match `prefabs/<name>.json`.
191///
192/// `measured_min_light` / `measured` are `Option` in the type but **not**
193/// optional in the data: a measured profile without them, or an `unmeasured`
194/// profile with them, is refused at deserialisation (see the hand-written
195/// `Deserialize` below). The optionality expresses which profile is being
196/// declared, never a licence to omit a measurement that was claimed.
197#[derive(Clone, Debug, PartialEq, Serialize, JsonSchema)]
198#[serde(deny_unknown_fields)]
199pub struct Lighting {
200    /// The declared profile.
201    pub profile: LightingProfile,
202    /// The measured minimum floor light level. Present iff the profile is a
203    /// measurement.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub measured_min_light: Option<i64>,
206    /// The date the level was measured (`YYYY-MM-DD`). Present iff the profile
207    /// is a measurement.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub measured: Option<String>,
210    /// Why `dim`/`dark` was chosen (required for `dim`/`dark` by review).
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub rationale: Option<String>,
213    /// How the minimum was measured (provenance breadcrumb; optional).
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub method: Option<String>,
216}
217
218impl Lighting {
219    /// A never-probed declaration — what a generated prefab exports.
220    pub fn unmeasured() -> Lighting {
221        Lighting {
222            profile: LightingProfile::Unmeasured,
223            measured_min_light: None,
224            measured: None,
225            rationale: None,
226            method: None,
227        }
228    }
229}
230
231/// The wire shape of [`Lighting`], before the profile/measurement agreement is
232/// checked. Split out so the check runs on every deserialisation path there is.
233#[derive(Deserialize)]
234#[serde(deny_unknown_fields)]
235struct RawLighting {
236    profile: LightingProfile,
237    #[serde(default)]
238    measured_min_light: Option<i64>,
239    #[serde(default)]
240    measured: Option<String>,
241    #[serde(default)]
242    rationale: Option<String>,
243    #[serde(default)]
244    method: Option<String>,
245}
246
247impl<'de> Deserialize<'de> for Lighting {
248    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Lighting, D::Error> {
249        use serde::de::Error as _;
250        let raw = RawLighting::deserialize(de)?;
251        let has_measurement = raw.measured_min_light.is_some() && raw.measured.is_some();
252        if raw.profile.is_measurement() {
253            // Exactly the requirement the previously-mandatory fields enforced:
254            // claiming `lit`/`dim`/`dark` means claiming a probe ran.
255            if !has_measurement {
256                return Err(D::Error::custom(
257                    "a `lit`/`dim`/`dark` lighting profile is a measurement and must carry both \
258                     `measured_min_light` and `measured`; declare `\"profile\": \"unmeasured\"` \
259                     if the piece has not been probed yet",
260                ));
261            }
262        } else if raw.measured_min_light.is_some() || raw.measured.is_some() {
263            return Err(D::Error::custom(
264                "an `unmeasured` lighting profile cannot carry `measured_min_light` or \
265                 `measured` — declare the profile the probe actually found instead",
266            ));
267        }
268        Ok(Lighting {
269            profile: raw.profile,
270            measured_min_light: raw.measured_min_light,
271            measured: raw.measured,
272            rationale: raw.rationale,
273            method: raw.method,
274        })
275    }
276}
277
278/// The prefab-metadata surface DSL validation resolves refs against: declared
279/// anchors, prefab-pool existence, and lighting profiles. v0 ships a small
280/// vendored implementation; the compiler injects the real one from `prefabs/`.
281pub trait AnchorRegistry {
282    /// The DSL anchor ids (`anchor/exit`, …) the prefab provides, or `None` if
283    /// the prefab is unknown to this registry (defer to the compiler).
284    fn anchors_for(&self, prefab: &PrefabId) -> Option<&BTreeSet<String>>;
285
286    /// True if a prefab pool with this id exists in the metadata surface.
287    fn has_pool(&self, _pool: &PoolId) -> bool {
288        false
289    }
290
291    /// Whether a prefab with this id exists in the metadata surface, when this
292    /// registry is in a position to answer: `Some(true)`/`Some(false)` from a
293    /// registry that IS the whole library, `None` from one that is not.
294    ///
295    /// [`Self::anchors_for`] cannot answer this and must not be made to. Its
296    /// `None` is deliberately lenient — *this registry has not heard of the
297    /// piece, so defer* — and every anchor proof in `validate` reads it that
298    /// way, skipping the area rather than refusing it. That leniency is
299    /// correct for a partial registry and catastrophic for a complete one: a
300    /// mistyped `prefab` then removes the area from the anchor index, and the
301    /// per-area anchor proof (`DW0142`) over every quest in it examines
302    /// nothing and passes. The two questions are different and they are asked
303    /// separately for exactly that reason (`DW0856`).
304    ///
305    /// The default is `None` — the safe answer for a test double or a subset
306    /// registry, which refuses nothing it cannot vouch for. The compiler's
307    /// `PrefabRegistry` loads a whole `prefabs/` directory and answers.
308    fn has_prefab(&self, _prefab: &PrefabId) -> Option<bool> {
309        None
310    }
311
312    /// The declared lighting for a prefab, if known.
313    fn lighting_for(&self, _prefab: &PrefabId) -> Option<Lighting> {
314        None
315    }
316
317    /// What the library holds for a rig id (`rig/<name>`, spec-0082 §3.1).
318    ///
319    /// The default is [`RigLookup::Unknown`](crate::rig::RigLookup::Unknown) —
320    /// the answer of a registry that is not the whole library, on whose word
321    /// nothing is refused, exactly as [`Self::has_prefab`] defaults to `None`.
322    /// The compiler's `PrefabRegistry` reads `<library>/rigs/` and answers.
323    fn rig(&self, _rig: &crate::ids::RigId) -> crate::rig::RigLookup<'_> {
324        crate::rig::RigLookup::Unknown
325    }
326}
327
328/// Vendored item registry loaded from an embedded JSON array of item ids.
329#[derive(Debug, Clone)]
330pub struct VendoredItemRegistry {
331    ids: BTreeSet<String>,
332}
333
334impl VendoredItemRegistry {
335    /// The v0 subset of the 1.21.11 item registry used by the M1 fixtures, plus
336    /// the four [`crate::POTION_BEARING_ITEMS`] — a kit item's potion
337    /// `contents` is DSL surface with its own rules (`DW0486`/`DW0487`), so the
338    /// crate's own tests must be able to name the items those rules are about
339    /// without the compiler's full injected registry.
340    pub fn v1_21_11() -> Self {
341        let raw = include_str!("../data/items-1.21.11.json");
342        let ids: Vec<String> =
343            serde_json::from_str(raw).expect("embedded item registry is valid JSON");
344        Self {
345            ids: ids.into_iter().collect(),
346        }
347    }
348}
349
350impl ItemRegistry for VendoredItemRegistry {
351    fn contains(&self, item_id: &str) -> bool {
352        self.ids.contains(item_id)
353    }
354}
355
356/// Vendored entity registry loaded from an embedded JSON array of entity ids
357/// (the common hostile mobs used by v0.3 wave fixtures).
358#[derive(Debug, Clone)]
359pub struct VendoredEntityRegistry {
360    ids: BTreeSet<String>,
361}
362
363impl VendoredEntityRegistry {
364    /// The v0 subset of the 1.21.11 entity registry used by wave fixtures.
365    pub fn v1_21_11() -> Self {
366        let raw = include_str!("../data/entities-1.21.11.json");
367        let ids: Vec<String> =
368            serde_json::from_str(raw).expect("embedded entity registry is valid JSON");
369        Self {
370            ids: ids.into_iter().collect(),
371        }
372    }
373}
374
375impl EntityRegistry for VendoredEntityRegistry {
376    fn contains(&self, entity_id: &str) -> bool {
377        self.ids.contains(entity_id)
378    }
379}
380
381// ---------------------------------------------------------------------------
382// DSL v0.4 registries: blocks (props / set-block) and status effects
383// ---------------------------------------------------------------------------
384
385/// Membership test for vanilla block ids (`minecraft:lever`, …), used to
386/// validate DSL v0.4 `set-block` / `interact.prop` block ids (`DW0193`).
387pub trait BlockRegistry {
388    /// True if `block_id` is a known placeable block in the pinned MC version.
389    fn contains(&self, block_id: &str) -> bool;
390}
391
392/// The complete 1.21.11 **enchantment** id list. Same rationale as
393/// [`EFFECT_IDS_1_21_11`]: 43 ids, stable across the pinned MC version, so it is
394/// inlined rather than vendored as a data file and injected. Extracted from the
395/// `enchantment` registry of the same misode/mcmeta 1.21.11 registries summary
396/// the item/entity registries come from (SHA-256
397/// `7efb184902cfef62b431bc9826ebcbcde2c23746e5624326ffcf922e15cf28f9`, pinned in
398/// `crates/delvec/data/PROVENANCE.md`) — Mojang's own generated data, not a
399/// third-party reconstruction.
400pub const ENCHANTMENT_IDS_1_21_11: &[&str] = &[
401    "minecraft:aqua_affinity",
402    "minecraft:bane_of_arthropods",
403    "minecraft:binding_curse",
404    "minecraft:blast_protection",
405    "minecraft:breach",
406    "minecraft:channeling",
407    "minecraft:density",
408    "minecraft:depth_strider",
409    "minecraft:efficiency",
410    "minecraft:feather_falling",
411    "minecraft:fire_aspect",
412    "minecraft:fire_protection",
413    "minecraft:flame",
414    "minecraft:fortune",
415    "minecraft:frost_walker",
416    "minecraft:impaling",
417    "minecraft:infinity",
418    "minecraft:knockback",
419    "minecraft:looting",
420    "minecraft:loyalty",
421    "minecraft:luck_of_the_sea",
422    "minecraft:lunge",
423    "minecraft:lure",
424    "minecraft:mending",
425    "minecraft:multishot",
426    "minecraft:piercing",
427    "minecraft:power",
428    "minecraft:projectile_protection",
429    "minecraft:protection",
430    "minecraft:punch",
431    "minecraft:quick_charge",
432    "minecraft:respiration",
433    "minecraft:riptide",
434    "minecraft:sharpness",
435    "minecraft:silk_touch",
436    "minecraft:smite",
437    "minecraft:soul_speed",
438    "minecraft:sweeping_edge",
439    "minecraft:swift_sneak",
440    "minecraft:thorns",
441    "minecraft:unbreaking",
442    "minecraft:vanishing_curse",
443    "minecraft:wind_burst",
444];
445
446/// Membership test for vanilla enchantment ids (`minecraft:sharpness`, …), used
447/// to validate actor/wave `equipment` and `loot` enchantments (`DW0433`).
448pub trait EnchantmentRegistry {
449    /// True if `enchantment_id` is a known enchantment in the pinned MC version.
450    fn contains(&self, enchantment_id: &str) -> bool;
451}
452
453/// Vendored enchantment registry built from [`ENCHANTMENT_IDS_1_21_11`].
454#[derive(Debug, Clone)]
455pub struct VendoredEnchantmentRegistry {
456    ids: BTreeSet<&'static str>,
457}
458
459impl VendoredEnchantmentRegistry {
460    /// The full 1.21.11 enchantment registry.
461    pub fn v1_21_11() -> Self {
462        Self {
463            ids: ENCHANTMENT_IDS_1_21_11.iter().copied().collect(),
464        }
465    }
466}
467
468impl Default for VendoredEnchantmentRegistry {
469    fn default() -> Self {
470        Self::v1_21_11()
471    }
472}
473
474impl EnchantmentRegistry for VendoredEnchantmentRegistry {
475    fn contains(&self, enchantment_id: &str) -> bool {
476        let norm = if enchantment_id.contains(':') {
477            enchantment_id.to_string()
478        } else {
479            format!("minecraft:{enchantment_id}")
480        };
481        self.ids.contains(norm.as_str())
482    }
483}
484
485/// The complete 1.21.11 **potion** id list — the `potion` registry a
486/// `minecraft:potion_contents` component's `potion` field names
487/// (`minecraft:strong_healing`, …). 46 ids, extracted from the same
488/// misode/mcmeta 1.21.11 registries summary the item/entity/enchantment lists
489/// come from (SHA-256
490/// `7efb184902cfef62b431bc9826ebcbcde2c23746e5624326ffcf922e15cf28f9`, pinned in
491/// `crates/delvec/data/PROVENANCE.md`) — Mojang's own generated data.
492///
493/// Complete and stable for the pinned version, so unlike the item/entity
494/// registries there is **nothing for the compiler to inject**: this const IS the
495/// registry, and [`is_potion_id`] is the whole membership test (same treatment as
496/// [`VendoredBlockRegistry`]).
497pub const POTION_IDS_1_21_11: &[&str] = &[
498    "minecraft:awkward",
499    "minecraft:fire_resistance",
500    "minecraft:harming",
501    "minecraft:healing",
502    "minecraft:infested",
503    "minecraft:invisibility",
504    "minecraft:leaping",
505    "minecraft:long_fire_resistance",
506    "minecraft:long_invisibility",
507    "minecraft:long_leaping",
508    "minecraft:long_night_vision",
509    "minecraft:long_poison",
510    "minecraft:long_regeneration",
511    "minecraft:long_slow_falling",
512    "minecraft:long_slowness",
513    "minecraft:long_strength",
514    "minecraft:long_swiftness",
515    "minecraft:long_turtle_master",
516    "minecraft:long_water_breathing",
517    "minecraft:long_weakness",
518    "minecraft:luck",
519    "minecraft:mundane",
520    "minecraft:night_vision",
521    "minecraft:oozing",
522    "minecraft:poison",
523    "minecraft:regeneration",
524    "minecraft:slow_falling",
525    "minecraft:slowness",
526    "minecraft:strength",
527    "minecraft:strong_harming",
528    "minecraft:strong_healing",
529    "minecraft:strong_leaping",
530    "minecraft:strong_poison",
531    "minecraft:strong_regeneration",
532    "minecraft:strong_slowness",
533    "minecraft:strong_strength",
534    "minecraft:strong_swiftness",
535    "minecraft:strong_turtle_master",
536    "minecraft:swiftness",
537    "minecraft:thick",
538    "minecraft:turtle_master",
539    "minecraft:water",
540    "minecraft:water_breathing",
541    "minecraft:weakness",
542    "minecraft:weaving",
543    "minecraft:wind_charged",
544];
545
546/// True if `id` (optionally un-namespaced) is a 1.21.11 potion id.
547pub fn is_potion_id(id: &str) -> bool {
548    let norm = if id.contains(':') {
549        id.to_string()
550    } else {
551        format!("minecraft:{id}")
552    };
553    POTION_IDS_1_21_11.contains(&norm.as_str())
554}
555
556/// Membership test for vanilla status-effect ids (`minecraft:slowness`, …),
557/// used to validate DSL v0.4 wave-mob `effects` (`DW0192`) and a kit item's
558/// potion `contents` effects (`DW0486`).
559pub trait EffectRegistry {
560    /// True if `effect_id` is a known status effect in the pinned MC version.
561    fn contains(&self, effect_id: &str) -> bool;
562}
563
564/// The complete 1.21.11 mob status-effect id list (the canonical source both the
565/// vendored and full [`EffectRegistry`]s build from). Small, stable registry, so
566/// it is inlined rather than a data file.
567pub const EFFECT_IDS_1_21_11: &[&str] = &[
568    "minecraft:speed",
569    "minecraft:slowness",
570    "minecraft:haste",
571    "minecraft:mining_fatigue",
572    "minecraft:strength",
573    "minecraft:instant_health",
574    "minecraft:instant_damage",
575    "minecraft:jump_boost",
576    "minecraft:nausea",
577    "minecraft:regeneration",
578    "minecraft:resistance",
579    "minecraft:fire_resistance",
580    "minecraft:water_breathing",
581    "minecraft:invisibility",
582    "minecraft:blindness",
583    "minecraft:night_vision",
584    "minecraft:hunger",
585    "minecraft:weakness",
586    "minecraft:poison",
587    "minecraft:wither",
588    "minecraft:health_boost",
589    "minecraft:absorption",
590    "minecraft:saturation",
591    "minecraft:glowing",
592    "minecraft:levitation",
593    "minecraft:luck",
594    "minecraft:unluck",
595    "minecraft:slow_falling",
596    "minecraft:conduit_power",
597    "minecraft:dolphins_grace",
598    "minecraft:bad_omen",
599    "minecraft:hero_of_the_village",
600    "minecraft:darkness",
601    "minecraft:trial_omen",
602    "minecraft:raid_omen",
603    "minecraft:wind_charged",
604    "minecraft:weaving",
605    "minecraft:oozing",
606    "minecraft:infested",
607    // Registry-fidelity fix (2026-08-03): the pinned 1.21.11 `mob_effect`
608    // registry has 40 entries and this list had 39 — the one 1.21.11 addition
609    // was missing, so a campaign naming it was rejected as unknown by a check
610    // that was simply out of date. Re-derived from the same pinned summary
611    // (SHA-256 `7efb1849…`) the potion list above comes from.
612    "minecraft:breath_of_the_nautilus",
613];
614
615/// A status-effect id in its canonical namespaced form: `blindness` and
616/// `minecraft:blindness` are the same effect to vanilla and must be the same
617/// effect to the compiler.
618///
619/// One spelling for one fact. This normalization existed twice — inside
620/// [`VendoredEffectRegistry::contains`] and inside
621/// [`crate::MobEffect::is_instant`] — before `DW0540` needed a third
622/// copy to decide whether a `clear-effect` removes the effect a `give-effect`
623/// granted. Two ids that the registry accepts as the same must not be two ids to
624/// a rule that pairs them, and a private third copy is how they drift apart.
625pub fn namespaced_effect_id(id: &str) -> String {
626    if id.contains(':') {
627        id.to_string()
628    } else {
629        format!("minecraft:{id}")
630    }
631}
632
633/// The [`BlockRegistry`] of the pinned 1.21.11 jar: every block id the game has,
634/// from `crates/dsl/data/blocks-1.21.11.json` ([`crate::blocks::BlockRegistry`]).
635///
636/// Blocks and items are different registries. A block with no item form
637/// (`minecraft:wall_torch`, wall signs, `minecraft:fire`, crops) is a valid
638/// placement target, and an item that is not a block (`minecraft:diamond`) is
639/// not; every site that places a block by id asks this registry, never the item
640/// registry. A bare id is read as `minecraft:`-namespaced, as the game does.
641#[derive(Debug, Clone, Copy, Default)]
642pub struct VendoredBlockRegistry;
643
644impl BlockRegistry for VendoredBlockRegistry {
645    fn contains(&self, block_id: &str) -> bool {
646        let reg = crate::blocks::BlockRegistry::v1_21_11();
647        if block_id.contains(':') {
648            reg.has(block_id)
649        } else {
650            reg.has(&format!("minecraft:{block_id}"))
651        }
652    }
653}
654
655/// Vendored status-effect registry built from [`EFFECT_IDS_1_21_11`].
656#[derive(Debug, Clone)]
657pub struct VendoredEffectRegistry {
658    ids: BTreeSet<&'static str>,
659}
660
661impl VendoredEffectRegistry {
662    /// The full 1.21.11 status-effect registry.
663    pub fn v1_21_11() -> Self {
664        Self {
665            ids: EFFECT_IDS_1_21_11.iter().copied().collect(),
666        }
667    }
668}
669
670impl Default for VendoredEffectRegistry {
671    fn default() -> Self {
672        Self::v1_21_11()
673    }
674}
675
676impl EffectRegistry for VendoredEffectRegistry {
677    fn contains(&self, effect_id: &str) -> bool {
678        self.ids.contains(namespaced_effect_id(effect_id).as_str())
679    }
680}
681
682/// Vendored anchor registry loaded from embedded prefab metadata.
683#[derive(Debug, Clone)]
684pub struct VendoredAnchorRegistry {
685    by_prefab: BTreeMap<String, BTreeSet<String>>,
686    pools: BTreeSet<String>,
687}
688
689impl VendoredAnchorRegistry {
690    /// The anchors declared by the M1 hello-world prefab(s), plus a fixture pool
691    /// so prefab-pool existence checks are exercised.
692    pub fn hello_world() -> Self {
693        let raw = include_str!("../data/anchors.json");
694        let by_prefab: BTreeMap<String, BTreeSet<String>> =
695            serde_json::from_str(raw).expect("embedded anchor metadata is valid JSON");
696        let pools_raw = include_str!("../data/pools.json");
697        let pools: BTreeSet<String> =
698            serde_json::from_str(pools_raw).expect("embedded pool metadata is valid JSON");
699        Self { by_prefab, pools }
700    }
701}
702
703impl AnchorRegistry for VendoredAnchorRegistry {
704    fn anchors_for(&self, prefab: &PrefabId) -> Option<&BTreeSet<String>> {
705        self.by_prefab.get(prefab.as_str())
706    }
707
708    fn has_pool(&self, pool: &PoolId) -> bool {
709        self.pools.contains(pool.as_str())
710    }
711
712    /// Closed, exactly as [`Self::has_pool`] already is: the embedded metadata
713    /// is the whole library this registry stands for, so a piece it does not
714    /// hold does not exist as far as anything validating against it is
715    /// concerned. Answering `None` here would leave the crate's own validation
716    /// entry point unable to see the defect it validates for.
717    fn has_prefab(&self, prefab: &PrefabId) -> Option<bool> {
718        Some(self.by_prefab.contains_key(prefab.as_str()))
719    }
720}
721
722#[cfg(test)]
723mod lighting_tests {
724    use super::*;
725
726    /// A prefab's lighting profile is a claim about a measurement, so the
727    /// measurement must be there. This was enforced by two mandatory fields
728    /// until `unmeasured` needed them absent; the rule itself did not move.
729    #[test]
730    fn a_measured_profile_still_cannot_omit_its_measurement() {
731        for json in [
732            r#"{ "profile": "lit" }"#,
733            r#"{ "profile": "lit", "measured_min_light": 9 }"#,
734            r#"{ "profile": "lit", "measured": "2026-07-30" }"#,
735            r#"{ "profile": "dim", "measured": "2026-07-30" }"#,
736            r#"{ "profile": "dark", "measured_min_light": 1 }"#,
737        ] {
738            let err = serde_json::from_str::<Lighting>(json).unwrap_err();
739            assert!(
740                err.to_string().contains("must carry both"),
741                "{json} was accepted or misreported: {err}"
742            );
743        }
744    }
745
746    /// ...and the converse, which is the new way to lie: declaring the probe
747    /// never ran while quoting what it found.
748    #[test]
749    fn an_unmeasured_profile_cannot_quote_a_measurement() {
750        for json in [
751            r#"{ "profile": "unmeasured", "measured_min_light": 9 }"#,
752            r#"{ "profile": "unmeasured", "measured": "2026-07-30" }"#,
753            r#"{ "profile": "unmeasured", "measured_min_light": 9, "measured": "2026-07-30" }"#,
754        ] {
755            let err = serde_json::from_str::<Lighting>(json).unwrap_err();
756            assert!(err.to_string().contains("cannot carry"), "{json}: {err}");
757        }
758    }
759
760    /// The two shapes that are true: a hand-probed piece and a generated one.
761    #[test]
762    fn both_honest_declarations_round_trip() {
763        let measured: Lighting = serde_json::from_str(
764            r#"{ "profile": "lit", "measured_min_light": 8, "measured": "2026-07-30",
765                 "method": "live 1.21.11 probe" }"#,
766        )
767        .unwrap();
768        assert_eq!(measured.profile, LightingProfile::Lit);
769        assert_eq!(measured.measured_min_light, Some(8));
770        assert!(measured.profile.is_measurement());
771
772        let unmeasured: Lighting = serde_json::from_str(r#"{ "profile": "unmeasured" }"#).unwrap();
773        assert_eq!(unmeasured, Lighting::unmeasured());
774        assert!(!unmeasured.profile.is_measurement());
775
776        // Serialisation omits what is absent rather than writing `null`, so a
777        // re-serialised file is the file that was read.
778        for l in [measured, unmeasured] {
779            let text = serde_json::to_string(&l).unwrap();
780            assert!(!text.contains("null"), "{text}");
781            assert_eq!(serde_json::from_str::<Lighting>(&text).unwrap(), l);
782        }
783    }
784
785    /// An unknown profile string is still refused outright — `unmeasured` is a
786    /// named state, not an escape hatch for anything the reader does not know.
787    #[test]
788    fn an_unknown_profile_is_still_refused() {
789        assert!(serde_json::from_str::<Lighting>(r#"{ "profile": "unknown" }"#).is_err());
790        assert!(
791            serde_json::from_str::<Lighting>(
792                r#"{ "profile": "lit", "measured_min_light": 8, "measured": "x", "extra": 1 }"#
793            )
794            .is_err(),
795            "deny_unknown_fields must survive the hand-written Deserialize"
796        );
797    }
798}
799
800#[cfg(test)]
801mod block_registry_tests {
802    use super::*;
803
804    #[test]
805    fn the_block_registry_is_not_the_item_registry() {
806        let r = VendoredBlockRegistry;
807        for block_without_item in [
808            "minecraft:wall_torch",
809            "minecraft:fire",
810            "minecraft:oak_wall_sign",
811            "minecraft:air",
812            "minecraft:water",
813            "lava",
814        ] {
815            assert!(r.contains(block_without_item), "{block_without_item}");
816        }
817        for not_a_block in [
818            "minecraft:diamond",
819            "minecraft:iron_sword",
820            "minecraft:nope",
821        ] {
822            assert!(!r.contains(not_a_block), "{not_a_block}");
823        }
824    }
825}