Skip to main content

delvewright_dsl/
l10n.rs

1//! Native i18n: author-declared languages, l10n sidecar documents, the
2//! authoritative key inventory, and the localization pass (spec-0001 i18n
3//! addendum).
4//!
5//! **English is canonical.** Stage docs stay pure English; the strings the
6//! compiler emits to players come from the stage docs. A campaign that declares
7//! `world.languages = ["<code>", …]` must ship one `l10n/<code>.json` sidecar per
8//! declared language, each a flat map of **stable key → translated string**.
9//!
10//! The **key inventory** ([`inventory`]) is derived deterministically from the
11//! stage docs; it is the single source of truth for both coverage validation
12//! ([`validate_l10n`]) and the build-time swap ([`localize`]). Both walk the exact
13//! same traversal ([`each_string`]), so a key can never be checked but not applied
14//! (or vice-versa).
15//!
16//! ## Key scheme (stable, path-derived, collision-free)
17//!
18//! Keys are dotted paths built from the local part of each DSL id (the segment
19//! after `<prefix>/`, kebab preserved). Ids are unique within their namespace, so
20//! every key is unique.
21//!
22//! | Key | Source string |
23//! |-----|---------------|
24//! | `world.title` | stage-1 `content.title` |
25//! | `area.<area>.name` | each stage-1 area `name` |
26//! | `class.<class>.name` / `.blurb` | each stage-3 class |
27//! | `class.<class>.kit.<i>.name` | a kit item's display `name` (only if set) |
28//! | `npc.<npc>.name` | each stage-2 NPC `name` (see *entity display names* below) |
29//! | `actor.<actor>.name` | each stage-5 actor `name` (v0.6, only if set; see below) |
30//! | `quest.<quest>.goal` | each stage-4 planned-quest `goal` |
31//! | `obj.<quest>.<obj>.title` / `.hint` | a stage-5 objective's `title`/`hint` (only if set) |
32//! | `obj.<quest>.<obj>.missing_item_hint` | a stage-5 `interact`'s `missing_item_hint` (v0.7, only if set) |
33//! | `obj.<quest>.<obj>.item_name` | a stage-5 `collect`'s `item_name` (v0.8, only if set) |
34//! | `dlg.<npc>.<node>.text` | each stage-6 dialogue node `text` |
35//! | `dlg.<npc>.<node>.opt.<i>.label` | each dialogue option `label` |
36//! | `dlg.<npc>.<node>.opt.<i>.tooltip` | that option's hover `tooltip` (v0.8, only if set) |
37//! | `wave.<wave>.mob.<i>.name` | a wave mob's custom `name` (only if set) |
38//! | `wave.<wave>.mob.<i>.drop.<n>.name` | a declared quest-item drop's display `name` (v0.9, only if set) |
39//! | `actor.<actor>.drop.<n>.name` | an actor's declared quest-item drop `name` (v0.9, only if set) |
40//! | `fx.…​.narrate` / `fx.…​.give` | a `narrate` line / named `give-item` in an effect list |
41//! | `fx.…​.rest_prompt` / `.rest_label` / `.save_label` | a `bonfire`'s authored rest-dialog strings (v0.8, only if set) |
42//! | `fx.…​.sealed_hint` | a `close-gate`'s authored answer to a right-click on the seal (v0.8, only if set) |
43//! | `lethal.<volume>.message` | a stage-5 lethal volume's death wording (v0.10) |
44//! | `state.<datum>.name` | a runtime datum's player-visible name — a currency (v0.10) |
45//! | `shop.<shop>.title` | a stage-5 shop dialog's title (v0.10) |
46//! | `shop.<shop>.offer.<i>.label` | a shop button's caption (v0.10) |
47//! | `shop.<shop>.offer.<i>.tooltip` | a shop button's hover tooltip (v0.10) |
48//! | `stake.<stake>.collected` | what collecting a recovery stake says (v0.10) |
49//!
50//! ## Nested effects (DSL v0.6)
51//!
52//! Effect strings nested inside a `sequence` step or a lifecycle bundle
53//! (`on_respawn`/`on_caught`/`on_arrive`) are player-visible too, so they are
54//! inventoried under **position-derived** child keys: the parent effect's `fx.…`
55//! key, then a stable segment ([`crate::stages::QuestEffect::nested_effect_lists_keyed_mut`])
56//! — `seq.<step>` for a sequence step, `respawn`/`caught`/`arrive` for the bundles —
57//! then the effect's index in that list, then the leaf (`.narrate`/`.give`).
58//! Example: a narrate in sequence step 1, effect 0 of `on_objective_complete`
59//! effect 0 → `fx.<quest>.oc.<obj>.0.seq.1.0.narrate`. Nesting is arbitrary-depth
60//! (a `move-actor.on_arrive` inside a `sequence` step nests both segments). Keys are
61//! purely position-derived → deterministic and stable across builds (ADR-0006).
62//!
63//! ## Entity display names are keyed by their TEXT, not by their site
64//!
65//! An NPC (`npc.<npc>.name`) and a scripted actor (`actor.<actor>.name`) are two
66//! DSL surfaces for the same thing a player reads: a nameplate over a body. One
67//! character routinely occupies both — a stage-2 NPC that stands and talks, plus
68//! one actor puppet per cutscene pose it is staged in. If each site owned its own
69//! key, a translator would be asked for `Polyphemus` five times and could answer
70//! differently each time, and the giant's name would **change as he walked into a
71//! cutscene** — a worse defect than the untranslated one, and an authored one.
72//!
73//! So the key of an entity display name is decided by its **canonical English
74//! text**: the first site (in this traversal's fixed order — NPCs before actors)
75//! declaring a given name owns the key, and every later site carrying the
76//! byte-identical name emits that same key. The inventory therefore asks for each
77//! distinct name exactly once, and two bodies a player reads as one character
78//! cannot render as two.
79//!
80//! Scope is deliberately the **entity display-name class only** (`npc.*.name`,
81//! `actor.*.name`). Prose — a narrate line, a dialogue label, an objective title —
82//! is context-bound and keeps one key per site: two English strings that happen to
83//! coincide may legitimately need different renderings. Wave-mob names
84//! (`wave.*.mob.*.name`) are the same shape and are **not** merged here: that is a
85//! generalization beyond the finding this rule closes, and it is an owner call
86//! because it retires keys live campaigns already translate.
87//!
88//! Player-visible strings only. Deliberately **excluded** (authoring context the
89//! player never sees, so translating them is pointless and out of scope): world
90//! `theme`/`premise`, NPC `persona` fields, persona `relationships`.
91
92use crate::stages::Verb;
93use std::collections::{BTreeMap, BTreeSet};
94
95use schemars::JsonSchema;
96use serde::{Deserialize, Serialize};
97
98use crate::diagnostic::{Diagnostic, codes};
99use crate::envelope::{Campaign, DSL_VERSION};
100use crate::ids::CampaignId;
101use crate::stages::{NarrateStyle, QuestEffect};
102
103/// Walk the player-visible strings of a single quest effect (DSL v0.4): a
104/// `narrate` line and a named `give-item`'s display name. `keybase` is the
105/// effect's stable position-derived key prefix.
106fn effect_strings(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
107    match &mut eff.verb {
108        Verb::Narrate { text, .. } => f(&format!("{keybase}.narrate"), text),
109        Verb::GiveItem { name: Some(n), .. } => f(&format!("{keybase}.give"), n),
110        // spec-0016 §1: the bonfire's rest dialog is
111        // read by the player like any other on-screen line, so its authored
112        // strings translate like any other. Unauthored fields are absent from the
113        // inventory — the compiler bakes its canonical English, exactly as
114        // `world.boundary.message` does.
115        Verb::Bonfire {
116            prompt,
117            rest_label,
118            save_label,
119            ..
120        } => {
121            if let Some(p) = prompt.as_mut() {
122                f(&format!("{keybase}.rest_prompt"), p);
123            }
124            if let Some(r) = rest_label.as_mut() {
125                f(&format!("{keybase}.rest_label"), r);
126            }
127            if let Some(s) = save_label.as_mut() {
128                f(&format!("{keybase}.save_label"), s);
129            }
130        }
131        // DSL v0.8: what a sealed gate answers when the party right-clicks it. Read
132        // off the actionbar exactly like a `narrate`, so it translates like one. An
133        // unauthored hint is absent from the inventory — the compiler bakes its
134        // canonical English, exactly as `world.boundary.message` does.
135        Verb::CloseGate {
136            sealed_hint: Some(h),
137            ..
138        } => f(&format!("{keybase}.sealed_hint"), h),
139        _ => {}
140    }
141}
142
143/// Walk the player-visible strings of `eff` **and every effect nested inside it**
144/// (DSL v0.6): a `narrate`/`give-item` inside a `sequence` step or an
145/// `on_respawn`/`on_caught`/`on_arrive` bundle is player-visible and must enter the
146/// inventory (and be localized on the emission path), else it ships English-only in
147/// a translated build. Child keys extend `keybase` with the effect's stable key
148/// segment ([`QuestEffect::nested_effect_lists_keyed_mut`]) and the effect's index
149/// within that list, e.g. `<keybase>.seq.<step>.<j>.narrate` for a narrate in
150/// sequence step `<step>`, effect `<j>`. Deterministic and stable across builds.
151fn effect_strings_deep(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
152    effect_strings(eff, keybase, f);
153    for (seg, list) in eff.nested_effect_lists_keyed_mut() {
154        for (j, inner) in list.iter_mut().enumerate() {
155            effect_strings_deep(inner, &format!("{keybase}.{seg}.{j}"), f);
156        }
157    }
158}
159
160/// The implicit, always-canonical language. Never appears in `world.languages`
161/// and never has a sidecar; `delvec build --lang en` emits the pure-English delve.
162pub const CANONICAL_LANG: &str = "en";
163
164/// The reserved sigil opening the compiler's machine-readable **completion-marker**
165/// channel — `[dw:complete <campaign_id> <token>]`, the only evidence the
166/// validation bot accepts that an objective (or the campaign) actually completed.
167/// The channel rides chat, so any authored or translated player-visible string
168/// carrying this sigil could forge a completion and make a critical-path step pass
169/// hollow. [`validate_marker_channel`] (`DW0182`) reserves it, making the collision
170/// structurally impossible instead of merely implausible.
171pub const MARKER_SIGIL: &str = "[dw:complete";
172
173/// Reserve the machine completion-marker channel (`DW0182`): no player-visible
174/// string — authored English (the whole [`inventory`]) or any declared language's
175/// sidecar rendition — may contain [`MARKER_SIGIL`]. Language-independent; runs on
176/// every `validate` / `analyze` / `build`. Checks translations too: a translator
177/// (LLM or human) copying the sigil through is exactly the forgery this closes.
178pub fn validate_marker_channel(
179    c: &Campaign,
180    sidecars: &BTreeMap<String, L10nDoc>,
181) -> Vec<Diagnostic> {
182    let mut d = Vec::new();
183    let mut flag = |where_: String, key: &str, text: &str| {
184        if text.contains(MARKER_SIGIL) {
185            d.push(Diagnostic::error(
186                codes::MARKER_RESERVED,
187                "l10n",
188                where_,
189                format!(
190                    "player-visible string `{key}` contains the reserved completion-marker \
191                     sigil `{MARKER_SIGIL}` — that chat sequence is the validation bot's \
192                     completion oracle, and authored text carrying it could forge a passing \
193                     critical-path step. Reword the line to drop `{MARKER_SIGIL}`"
194                ),
195            ));
196        }
197    };
198    for (key, text) in inventory(c) {
199        flag(format!("#/{key}"), &key, &text);
200    }
201    for (lang, doc) in sidecars {
202        for (key, text) in &doc.content {
203            flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
204        }
205    }
206    d
207}
208
209/// The kind marker on an l10n sidecar envelope (`"l10n"`), analogous to the stage
210/// marker on a stage document.
211#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "kebab-case")]
213pub enum L10nKind {
214    /// A localization sidecar (`l10n/<code>.json`).
215    L10n,
216}
217
218/// An l10n sidecar document: `{ dsl_version, campaign_id, kind: "l10n", lang,
219/// content, source }`, mirroring the stage-doc envelope style. `content` is a flat
220/// map of [inventory](crate::l10n::inventory) key → translated string; `source`
221/// records the canonical English each of those translations was made **from**.
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct L10nDoc {
225    /// DSL version string (same versioning as the stage docs).
226    pub dsl_version: String,
227    /// Owning campaign id (must match the stage docs).
228    pub campaign_id: CampaignId,
229    /// Document kind marker (`l10n`).
230    pub kind: L10nKind,
231    /// The BCP-47-style language code this sidecar translates into (must match the
232    /// `l10n/<code>.json` filename and appear in `world.languages`).
233    pub lang: String,
234    /// Flat map of inventory key → translated string.
235    pub content: BTreeMap<String, String>,
236    /// **Translation provenance**: inventory key → the canonical English that key
237    /// held when its [`Self::content`] row was written.
238    ///
239    /// Coverage validation proves the sidecar has a row for every key
240    /// (`DW0180`/`DW0181`), which is a statement about key SETS and says nothing
241    /// about whether a row still corresponds to the English it renders. Edit an
242    /// authored line and its translation is stale, present, applied, and wrong —
243    /// and nothing in the key sets moved. `source` is what makes that
244    /// **detectable** ([`validate_l10n_provenance`], `DW0187`) instead of audited.
245    ///
246    /// This is load-bearing for entity display names in particular, because their
247    /// key is owned by the first site declaring a given text (see the module
248    /// header): renaming ONE body can migrate a key's ownership to ANOTHER body,
249    /// so the row that goes wrong is not the row the author touched.
250    ///
251    /// Optional in the format — an older sidecar parses unchanged and simply
252    /// carries no provenance, which `DW0188` reports as an unguarded row count on
253    /// every run rather than letting it pass in silence.
254    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255    pub source: BTreeMap<String, String>,
256}
257
258/// The local part of a type-prefixed id: the segment after the first `/` (kebab
259/// preserved). `npc/keeper` → `keeper`. Ids without a `/` pass through unchanged.
260fn local(id: &str) -> &str {
261    id.split_once('/').map(|(_, r)| r).unwrap_or(id)
262}
263
264/// The local part of a type-prefixed DSL id, exactly as the key scheme derives it
265/// (`npc/keeper` → `keeper`). Public so a consumer that pairs inventory keys back
266/// with their source objects (`delvec l10n-inventory`, matching `dlg.<npc>.…` keys
267/// to the NPC that speaks them) derives the same segment the keys are built from.
268pub fn local_id(id: &str) -> &str {
269    local(id)
270}
271
272/// Walk every player-visible string in `c` in a fixed, deterministic order,
273/// invoking `f(key, &mut value)` for each. The single traversal shared by
274/// [`inventory`] and [`localize`] — they cannot drift.
275pub fn each_string(c: &mut Campaign, f: &mut dyn FnMut(&str, &mut String)) {
276    // Entity display names (a nameplate over a body) are keyed by their canonical
277    // English TEXT, not by their declaration site: canonical English → owning key.
278    // See the module header — one character routinely has one NPC identity and
279    // several actor puppets, and a per-site key would let its name be translated
280    // several ways. Filled in traversal order, so the NPC identity always owns the
281    // key and the puppets follow it.
282    let mut entity_names: BTreeMap<String, String> = BTreeMap::new();
283    // Stage 1 — world title + area names.
284    f("world.title", &mut c.world.content.title);
285    for area in &mut c.world.content.areas {
286        let key = format!("area.{}.name", local(area.id.as_str()));
287        f(&key, &mut area.name);
288    }
289    // Stage 1 — boundary return message (v0.6, only when authored). The compiler's
290    // default English is baked at emit time, so it is not inventoried; an authored
291    // message is translated like every other player-facing string.
292    if let Some(b) = c.world.content.boundary.as_mut()
293        && let Some(msg) = b.message.as_mut()
294    {
295        f("world.boundary.message", msg);
296    }
297    // Stage 1 — campaign outro (v0.6, only when authored): the closing line on the
298    // completion advancement. Unauthored, the emitter falls back to the finale
299    // quest's `goal`, which is inventoried in its own right — so the last sentence
300    // of a delve is campaign-derived and translated either way.
301    if let Some(outro) = c.world.content.outro.as_mut() {
302        f("world.outro", outro);
303    }
304    // Stage 3 — class names/blurbs + optional kit item display names.
305    for class in &mut c.classes.content.classes {
306        let cl = local(class.id.as_str()).to_string();
307        f(&format!("class.{cl}.name"), &mut class.name);
308        f(&format!("class.{cl}.blurb"), &mut class.blurb);
309        for (i, item) in class.kit.iter_mut().enumerate() {
310            if let Some(name) = item.name.as_mut() {
311                f(&format!("class.{cl}.kit.{i}.name"), name);
312            }
313        }
314    }
315    // Stage 2 — NPC names. First in the entity display-name class, so an NPC
316    // identity owns the key every actor puppet portraying it shares.
317    for npc in &mut c.npcs.content.npcs {
318        let key = entity_name_key(
319            &mut entity_names,
320            &npc.name,
321            format!("npc.{}.name", local(npc.id.as_str())),
322        );
323        f(&key, &mut npc.name);
324    }
325    // Stage 4 — quest goals.
326    for q in &mut c.quest_plan.content.quests {
327        let key = format!("quest.{}.goal", local(q.id.as_str()));
328        f(&key, &mut q.goal);
329    }
330    // Stage 5 — objective titles/hints (when set).
331    for q in &mut c.quests.content.quests {
332        let ql = local(q.id.as_str()).to_string();
333        for o in &mut q.objectives {
334            let ol = local(o.id().as_str()).to_string();
335            if let Some(title) = o.title_mut().as_mut() {
336                f(&format!("obj.{ql}.{ol}.title"), title);
337            }
338            if let Some(hint) = o.hint_mut().as_mut() {
339                f(&format!("obj.{ql}.{ol}.hint"), hint);
340            }
341            // Stage 5 — v0.7 `interact.missing_item_hint`: narrated in chat to the
342            // player who clicks without the required item in hand, so it is as
343            // player-visible as `hint` and translates like it. Absent on every
344            // pre-0.7 objective → inventory unchanged.
345            if let crate::stages::Objective::Interact {
346                missing_item_hint: Some(m),
347                ..
348            } = o
349            {
350                f(&format!("obj.{ql}.{ol}.missing_item_hint"), m);
351            }
352            // Stage 5 — v0.8 `collect.item_name`: the display name the
353            // collected item carries as a `custom_name` component. A player reads
354            // it off the stack in the barrel and off their own hotbar, so it is as
355            // player-visible as a `title` and translates like one. Absent on every
356            // pre-0.8 objective → inventory unchanged.
357            if let crate::stages::Objective::Collect {
358                item_name: Some(n), ..
359            } = o
360            {
361                f(&format!("obj.{ql}.{ol}.item_name"), n);
362            }
363        }
364        // Stage 5 — v0.7 cast-ledger bark lines (spec-0020). Barks are spoken
365        // in-game exactly like narrate text, so they translate like it too.
366        // `doing` is deliberately NOT inventoried: it is authoring context for
367        // the dialogue stage, never shown to a player. (`cast` is a BTreeMap and
368        // the placement list is ordered, so the traversal stays deterministic.)
369        for (npc, entry) in &mut q.cast {
370            let np = local(npc.as_str()).to_string();
371            for (b, p) in entry.placements_mut().into_iter().enumerate() {
372                let Some(crate::stages::CastDialogue::Barks(pool)) = p.dialogue.as_mut() else {
373                    continue;
374                };
375                for (i, line) in pool.barks.iter_mut().enumerate() {
376                    f(&format!("cast.{ql}.{np}.{b}.bark.{i}"), line);
377                }
378            }
379        }
380    }
381    // Stage 6 — dialogue node text + option labels.
382    for tree in &mut c.dialogue.content.dialogues {
383        let np = local(tree.npc.as_str()).to_string();
384        for node in &mut tree.nodes {
385            let nd = local(node.id.as_str()).to_string();
386            f(&format!("dlg.{np}.{nd}.text"), &mut node.text);
387            for (i, opt) in node.options.iter_mut().enumerate() {
388                f(&format!("dlg.{np}.{nd}.opt.{i}.label"), &mut opt.label);
389                // v0.8: the button's hover tooltip. A player reads it exactly as
390                // they read the caption, so it translates exactly like one; an
391                // unauthored tooltip is absent from the inventory (no key, no
392                // coverage obligation), like every other `only if set` string.
393                if let Some(tip) = opt.tooltip.as_mut() {
394                    f(&format!("dlg.{np}.{nd}.opt.{i}.tooltip"), tip);
395                }
396            }
397        }
398    }
399    // Stage 5 — wave mob custom names (when set).
400    for w in &mut c.quests.content.waves {
401        let wl = local(w.id.as_str()).to_string();
402        for (i, mob) in w.mobs.iter_mut().enumerate() {
403            if let Some(name) = mob.name.as_mut() {
404                f(&format!("wave.{wl}.mob.{i}.name"), name);
405            }
406            // Stage 5 — v0.9 declared quest-item drops. The name
407            // rides the dropped stack's `custom_name`, so the player reads it off
408            // the ground and off their own hotbar: as player-visible as a wave
409            // mob's own name, and translated like one.
410            for (n, dr) in mob.drops.iter_mut().enumerate() {
411                if let Some(name) = dr.name_mut() {
412                    f(&format!("wave.{wl}.mob.{i}.drop.{n}.name"), name);
413                }
414            }
415        }
416    }
417    // Stage 5 — actors: the nameplate over the puppet, then its v0.9 drops,
418    // keyed off the actor id exactly as a wave mob's drop is keyed off its
419    // wave.
420    for a in &mut c.quests.content.actors {
421        let al = local(a.id.as_str()).to_string();
422        // The puppet's own name (v0.6 `actors[].name`). Player-visible in every
423        // frame it stands in — a nameplate and, for a cutscene mannequin, the
424        // label the party reads while the scene plays — so it is as translatable
425        // as the stage-2 NPC name it usually duplicates, and shares that NPC's key
426        // when the two texts are identical (module header).
427        //
428        // `ACTOR_NAME_ENTRY` carries the whole fence: the field is v0.6 but the
429        // walk only reached it in the 0.10 era, so the coverage obligation is
430        // 0.10's and not v0.6's.
431        if let Some(name) = a.name.as_mut() {
432            let key = entity_name_key(&mut entity_names, name, format!("actor.{al}.name"));
433            f(&key, name);
434        }
435        for (n, dr) in a.drops.iter_mut().enumerate() {
436            if let Some(name) = dr.name_mut() {
437                f(&format!("actor.{al}.drop.{n}.name"), name);
438            }
439        }
440    }
441    // Stage 5 — loot item custom names (spec-0021), keyed like a class kit
442    // item's name so a named prop in a chest translates like any other.
443    for l in &mut c.quests.content.loot {
444        let ll = local(l.id.as_str()).to_string();
445        for (i, item) in l.items.iter_mut().enumerate() {
446            if let Some(name) = item.name.as_mut() {
447                f(&format!("loot.{ll}.item.{i}.name"), name);
448            }
449        }
450    }
451    // Stage 5 — lethal volumes (v0.10, spec-0031): the line the volume says as it
452    // kills. As player-visible as a narrate, and read at the worst possible moment
453    // to be reading a raw key, so it is inventoried like any other authored line.
454    for v in &mut c.quests.content.lethal_volumes {
455        let vl = local(v.id.as_str()).to_string();
456        f(&format!("lethal.{vl}.message"), &mut v.message);
457    }
458    // Stage 5 — a runtime datum's player-visible name (v0.10, spec-0032). A named
459    // datum is a currency: the engine states `<name>: <value>` on the holder's
460    // action bar on every write, so the name is read as often as any narrate.
461    for st in &mut c.quests.content.state {
462        let sl = local(st.id.as_str()).to_string();
463        if let Some(name) = st.name.as_mut() {
464            f(&format!("state.{sl}.name"), name);
465        }
466    }
467    // Stage 5 — shops (v0.10, spec-0032): the dialog's title, and each button's
468    // caption and tooltip. Keyed exactly as a dialogue node's label/tooltip are,
469    // because they are the same two components of the same vanilla button codec.
470    for sh in &mut c.quests.content.shops {
471        let hl = local(sh.id.as_str()).to_string();
472        f(&format!("shop.{hl}.title"), &mut sh.title);
473        for (i, off) in sh.offers.iter_mut().enumerate() {
474            f(&format!("shop.{hl}.offer.{i}.label"), &mut off.label);
475            if let Some(t) = off.tooltip.as_mut() {
476                f(&format!("shop.{hl}.offer.{i}.tooltip"), t);
477            }
478        }
479    }
480    // Stage 5 — recovery stakes (v0.10, spec-0032): the line a collection says.
481    for st in &mut c.quests.content.stakes {
482        let sl = local(st.id.as_str()).to_string();
483        f(&format!("stake.{sl}.collected"), &mut st.collected_message);
484    }
485    // v0.4 effect strings — `narrate` text, a named `give-item`, a bonfire's rest
486    // dialog, a seal's answer — over **every** root emission can lower an effect
487    // from, not just the quests stage's three ([`effect_roots_mut`]).
488    // Nesting inside each root is descended by `effect_strings_deep`, so a narrate
489    // in a `sequence` step of a trap payload is inventoried like any other.
490    for (_stage, _path, keybase, eff) in effect_roots_mut(c) {
491        effect_strings_deep(eff, &keybase, f);
492    }
493}
494
495/// The key an entity display name is inventoried under: the key already claimed by
496/// an identical name earlier in the traversal, or `own` if this site is the first
497/// to carry that text (in which case it claims it for every later site).
498///
499/// The lookup is on the string as authored, captured **before** `f` may rewrite it
500/// ([`localize`] swaps the NPC's name to the target language, and the actor puppets
501/// that follow are still English at the moment they are looked up).
502fn entity_name_key(claimed: &mut BTreeMap<String, String>, text: &str, own: String) -> String {
503    claimed.entry(text.to_string()).or_insert(own).clone()
504}
505
506/// The authoritative key → canonical-English inventory derived from the stage
507/// docs. Deterministic (keys are unique and the traversal order is fixed).
508///
509/// # Widening this is an ERROR-tier obligation on every existing campaign
510///
511/// This walk consults the campaign document and **never its `dsl_version`**.
512/// For the surface itself that is right — a campaign at 0.6.0 cannot use a 0.9
513/// surface, so those keys simply never appear — but it has a consequence worth
514/// stating, and one already paid for twice — by the widenings of
515/// [`each_string`] onto `traps[].payload` and onto `on_respawn` bundles:
516///
517/// **when a widening reaches strings that OLDER surfaces already emitted**, the
518/// inventory grows for campaigns of every declared version at once, and
519/// [`L10N_MISSING`](crate::codes::L10N_MISSING) (`DW0180`) is
520/// `Diagnostic::error`. A campaign that was complete and green stops building
521/// on the next engine, with no deprecation window and nothing in its own
522/// document changed.
523///
524/// Measured 2026-08-08 on `nobodys-cave-island` (sidecar `dsl_version` 0.6.0):
525/// removing one key — the shape a widening creates — exits 1 immediately with
526/// `DW0180 [error]`.
527///
528/// **The asymmetry is the finding, and it is with this module's own siblings.**
529/// One comparable obligation on existing content takes a warn-first window and
530/// says so in its own text: `DW0188` (translation provenance, in
531/// [`validate_l10n_provenance`] right here). Coverage does not. Whether it should is an owner call —
532/// changing a check's tier is never a mechanical change (CLAUDE.md) — so this
533/// records the measurement rather than acting on it.
534///
535/// Whichever way it is decided, the widening PR is where it has to be decided:
536/// adoption rounds for every active campaign belong in the same milestone as
537/// the `dsl_version` that creates the obligation, and a widening that skips the
538/// version bump creates the obligation with no milestone at all.
539pub fn inventory(c: &Campaign) -> BTreeMap<String, String> {
540    let mut c2 = c.clone();
541    let mut out = BTreeMap::new();
542    each_string(&mut c2, &mut |key, value| {
543        out.insert(key.to_string(), value.clone());
544    });
545    out
546}
547
548/// The NPC an inventory key belongs to (its **local** id), when the key scheme
549/// encodes one: `dlg.<npc>.…` (that NPC's dialogue tree — `.text` is the NPC's own
550/// line, `.opt.<i>.label` the player's reply *within* it), `npc.<npc>.name`, and
551/// `cast.<quest>.<npc>.…` (a v0.7 bark line — the NPC's own murmured speech, so a
552/// translator gets the same persona context a dialogue line gets).
553/// Returns `None` for every other key kind.
554///
555/// Lives beside [`each_string`] — the traversal that *defines* the key scheme — so
556/// the two cannot drift silently (a CLI test asserts every speaker derived from a
557/// real campaign's inventory resolves to a declared NPC). Consumed by
558/// `delvec l10n-inventory`, which hands a translator the speaking character's
559/// persona (`speech_style` above all) alongside the English line.
560pub fn key_speaker(key: &str) -> Option<&str> {
561    let (kind, rest) = key.split_once('.')?;
562    match kind {
563        "dlg" | "npc" => rest.split('.').next(),
564        // `cast.<quest>.<npc>.<branch>.bark.<i>` — the npc is the second segment.
565        "cast" => rest.split('.').nth(1),
566        _ => None,
567    }
568}
569
570/// One `narrate` `art` occurrence (DSL v0.6, spec-0014): its stage-doc path (for
571/// diagnostics), its l10n inventory key, and the canonical English text.
572#[derive(Clone, Debug, PartialEq, Eq)]
573pub struct ArtNarrate {
574    /// The stage document the string was authored in (`quests` / `dialogue`).
575    pub stage: &'static str,
576    /// JSON-pointer-ish path within that stage doc.
577    pub path: String,
578    /// The l10n inventory key (`fx.…​.narrate`) — always present, since every
579    /// `narrate` lives in an inventoried effect position.
580    pub key: String,
581    /// The canonical English text.
582    pub text: String,
583}
584
585/// Every `narrate` effect using the v0.6 `art` style, in a fixed deterministic
586/// order. Its l10n `key` is derived by the **same** traversal/keying as
587/// [`inventory`]/[`each_string`], so the compiler's art-font glyph check
588/// (`DW0328`) can look each art string up in every declared-language sidecar.
589/// Every art narrate lives in a quest `on_objective_complete`/`on_complete` or an
590/// environment trigger — all inventoried — so each `key` is guaranteed present in
591/// a fully-covered sidecar.
592pub fn art_narrates(c: &Campaign) -> Vec<ArtNarrate> {
593    let mut out = Vec::new();
594    each_effect_ref(c, &mut |stage, path, keybase, eff| {
595        if let Some(text) = eff.narrate_art_text() {
596            out.push(ArtNarrate {
597                stage,
598                path: format!("{path}/text"),
599                key: format!("{keybase}.narrate"),
600                text: text.to_string(),
601            });
602        }
603    });
604    out
605}
606
607/// One on-screen `narrate` occurrence — `title`, `subtitle` or `art` — its stage-doc
608/// path, its l10n inventory key, its style, and the canonical English text.
609#[derive(Clone, Debug, PartialEq, Eq)]
610pub struct ScreenNarrate {
611    /// The stage document the string was authored in (`quests` / `dialogue`).
612    pub stage: &'static str,
613    /// JSON-pointer-ish path within that stage doc.
614    pub path: String,
615    /// The l10n inventory key (`fx.…​.narrate`).
616    pub key: String,
617    /// Which on-screen channel vanilla draws it in — this selects the width budget.
618    pub style: NarrateStyle,
619    /// The canonical English text.
620    pub text: String,
621}
622
623/// Every `narrate` effect vanilla draws **on screen** (`title` / `subtitle` / `art`),
624/// in a fixed deterministic order. Like [`art_narrates`], each `key` is derived by the
625/// **same** traversal/keying as [`inventory`]/[`each_string`], so the compiler's
626/// text-fit check (`DW0330`) can look every string up in each declared-language
627/// sidecar and report it under the offending locale and key. `chat` narrates are
628/// excluded: chat wraps and scrolls, so it has no width budget.
629pub fn on_screen_narrates(c: &Campaign) -> Vec<ScreenNarrate> {
630    let mut out = Vec::new();
631    each_effect_ref(c, &mut |stage, path, keybase, eff| {
632        if let Some((style, text)) = eff.narrate_on_screen() {
633            out.push(ScreenNarrate {
634                stage,
635                path: format!("{path}/text"),
636                key: format!("{keybase}.narrate"),
637                style,
638                text: text.to_string(),
639            });
640        }
641    });
642    out
643}
644
645/// One dialogue option label — the caption vanilla draws on a fixed-width dialog
646/// button — with its stage-doc path, its l10n inventory key and the canonical
647/// English text.
648#[derive(Clone, Debug, PartialEq, Eq)]
649pub struct OptionLabel {
650    /// The stage document the string was authored in (`dialogue` for a real
651    /// dialogue option; a bonfire's labels carry the stage they were authored in).
652    pub stage: &'static str,
653    /// JSON-pointer-ish path within that stage doc.
654    pub path: String,
655    /// The l10n inventory key (`dlg.<npc>.<node>.opt.<i>.label`).
656    pub key: String,
657    /// The canonical English text.
658    pub text: String,
659}
660
661/// Every dialogue option label, in a fixed deterministic order (declaration order:
662/// tree, then node, then option). Each `key` is derived by the **same** keying as
663/// [`inventory`]/[`each_string`], so the compiler's button-width check (`DW0331`)
664/// can look every label up in each declared-language sidecar and report an
665/// overflowing translation under its own locale and key.
666///
667/// Every option label is emitted as a button caption exactly once per node variant
668/// (`emit::build_node_dialog`); display gating (`requires_flags`/`forbids_flags`)
669/// only decides *whether* a variant shows it, never how wide it renders, so gated
670/// and ungated options carry the same budget and are all visited here.
671pub fn dialogue_option_labels(c: &Campaign) -> Vec<OptionLabel> {
672    let mut out = Vec::new();
673    for (ti, tree) in c.dialogue.content.dialogues.iter().enumerate() {
674        let np = local(tree.npc.as_str());
675        for (ni, node) in tree.nodes.iter().enumerate() {
676            let nd = local(node.id.as_str());
677            for (oi, opt) in node.options.iter().enumerate() {
678                out.push(OptionLabel {
679                    stage: "dialogue",
680                    path: format!("/content/dialogues/{ti}/nodes/{ni}/options/{oi}/label"),
681                    key: format!("dlg.{np}.{nd}.opt.{oi}.label"),
682                    text: opt.label.clone(),
683                });
684            }
685        }
686    }
687    out
688}
689
690/// Every **authored** bonfire rest-dialog label (spec-0016 §1), in the same
691/// fixed effect order the inventory uses. A bonfire's
692/// two options are drawn on exactly the same 150-GUI-px `multi_action` button a
693/// dialogue option is, so they carry exactly the same width budget (`DW0331`) —
694/// the check follows the widget, not the stage the string was authored in.
695///
696/// Unauthored labels are absent by construction: the compiler's canonical English
697/// (`Rest and save` / `Save only` / `Bonfire`) is measured once by a compiler unit
698/// test rather than re-measured per campaign, since it cannot vary.
699pub fn bonfire_option_labels(c: &Campaign) -> Vec<OptionLabel> {
700    let mut out = Vec::new();
701    each_effect_ref(c, &mut |stage, path, keybase, eff| {
702        let Some(l) = eff.bonfire_labels() else {
703            return;
704        };
705        for (text, field, key) in [
706            (l.rest_label, "rest_label", "rest_label"),
707            (l.save_label, "save_label", "save_label"),
708        ] {
709            if let Some(text) = text {
710                out.push(OptionLabel {
711                    stage,
712                    path: format!("{path}/{field}"),
713                    key: format!("{keybase}.{key}"),
714                    text: text.to_string(),
715                });
716            }
717        }
718    });
719    out
720}
721
722/// The **five effect roots** the compiler can lower a quest effect from, as
723/// `(stage, json_path, l10n keybase)` for each root's `i`-th top-level effect.
724///
725/// The roots themselves are **not enumerated here**. They come from
726/// [`crate::effects::for_each_effect_root`], the one enumeration in the workspace,
727/// which this walk simply indexes into per top-level effect (`path` + `/{i}`,
728/// `key` + `.{i}`). Before that module existed this function held its own copy of
729/// the root list and [`effect_roots_mut`] held a second one, which is the
730/// arrangement that let a walk go blind to a root — twice, independently, in this
731/// file alone. The paths and keys are unchanged in both directions.
732fn effect_roots(c: &Campaign) -> Vec<EffectRoot<'_>> {
733    let mut out = Vec::new();
734    crate::effects::for_each_effect_root(c, &mut |site, list| {
735        for (i, eff) in list.iter().enumerate() {
736            out.push(EffectRoot {
737                stage: site.stage,
738                path: format!("{}/{i}", site.path),
739                key: format!("{}.{i}", site.key),
740                eff,
741            });
742        }
743    });
744    out
745}
746
747/// One top-level effect root: where it lives, what its l10n keys hang off, and the
748/// effect itself.
749struct EffectRoot<'a> {
750    /// The stage document (`quests` / `dialogue`) this effect was authored in.
751    stage: &'static str,
752    /// JSON pointer to the effect within that document.
753    path: String,
754    /// The effect's l10n key prefix.
755    key: String,
756    /// The effect.
757    eff: &'a QuestEffect,
758}
759
760/// The **mutable mirror** of [`effect_roots`]: the identical roots, in the
761/// identical order, with the same `(stage, path, key)` descriptors, exposed
762/// mutably so [`each_string`] (and through it [`localize`]) can rewrite the
763/// player-visible strings in place.
764///
765/// Like [`effect_roots`] it enumerates nothing itself — it indexes
766/// [`crate::effects::for_each_effect_root_mut`], which is generated from the
767/// **same macro body** as the immutable walk. The two mirrors are therefore
768/// lockstep by construction rather than by a test that has to be remembered; the
769/// descriptor-equality test below now pins that property instead of establishing
770/// it.
771fn effect_roots_mut(c: &mut Campaign) -> Vec<(&'static str, String, String, &mut QuestEffect)> {
772    let mut out: Vec<(&'static str, String, String, &mut QuestEffect)> = Vec::new();
773    crate::effects::for_each_effect_root_mut(c, &mut |kind, path, key, list| {
774        for (i, eff) in list.iter_mut().enumerate() {
775            out.push((
776                kind.stage(),
777                format!("{path}/{i}"),
778                format!("{key}.{i}"),
779                eff,
780            ));
781        }
782    });
783    out
784}
785
786/// Visit every effect emission can lower — **top-level and every
787/// transitively-nested** one (a `sequence` step, an
788/// `on_respawn`/`on_caught`/`on_arrive` bundle) — over all five
789/// [`effect_roots`], in the fixed inventory order, invoking
790/// `f(stage, path, keybase, effect)`. `stage` names the document the effect lives
791/// in (`quests` or `dialogue`) and `path` is its JSON pointer within it, so a
792/// diagnostic can point at the real site; `keybase` is its l10n key prefix, derived
793/// by the **same** position-keying as [`each_string`]/[`effect_strings_deep`] (so an
794/// art narrate's key matches its inventory key). Shared by [`art_narrates`],
795/// [`on_screen_narrates`], [`bonfire_option_labels`], [`sound_refs`] and
796/// [`play_sound_actor_refs`], so the consumer checks
797/// (`DW0326`/`DW0328`/`DW0330`/`DW0331`/`DW0335`) see exactly the strings the
798/// inventory demands a translation for.
799fn each_effect_ref<'a>(
800    c: &'a Campaign,
801    f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
802) {
803    for r in effect_roots(c) {
804        effect_deep(r.eff, r.stage, &r.path, &r.key, f);
805    }
806}
807
808/// Visit `eff` and every transitively-nested effect (depth-first, pre-order),
809/// threading the JSON-pointer `path` and l10n `keybase` through each nested list via
810/// [`QuestEffect::nested_effect_lists_labeled`] (path segment + key segment + the
811/// per-effect index). The key segments match [`effect_strings_deep`] exactly.
812fn effect_deep<'a>(
813    eff: &'a QuestEffect,
814    stage: &'static str,
815    path: &str,
816    keybase: &str,
817    f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
818) {
819    f(stage, path, keybase, eff);
820    for (pseg, kseg, list) in eff.nested_effect_lists_labeled() {
821        for (j, inner) in list.iter().enumerate() {
822            effect_deep(
823                inner,
824                stage,
825                &format!("{path}/{pseg}/{j}"),
826                &format!("{keybase}.{kseg}.{j}"),
827                f,
828            );
829        }
830    }
831}
832
833/// One vanilla sound-event reference (DSL v0.6/v0.4): its stage-doc path and the
834/// referenced id, for registry validation (`DW0326`).
835#[derive(Clone, Debug, PartialEq, Eq)]
836pub struct SoundRef {
837    /// The stage document the reference was authored in (`quests` / `dialogue`).
838    pub stage: &'static str,
839    /// JSON-pointer-ish path within that stage doc.
840    pub path: String,
841    /// The referenced sound-event id (`minecraft:` prefix optional).
842    pub sound: String,
843}
844
845/// Every vanilla sound-event id referenced by a quest/trigger effect — a
846/// `play-sound`'s `sound` (v0.6) and a `narrate`'s optional `sound` (v0.4) — in a
847/// fixed deterministic order, for `DW0326` validation.
848pub fn sound_refs(c: &Campaign) -> Vec<SoundRef> {
849    let mut out = Vec::new();
850    each_effect_ref(c, &mut |stage, path, _key, eff| {
851        for (sub, sound) in eff.sound_refs() {
852            out.push(SoundRef {
853                stage,
854                path: format!("{path}/{sub}"),
855                sound: sound.to_string(),
856            });
857        }
858    });
859    out
860}
861
862/// Every `play-sound` effect using the unsupported `at: actor` target, in a fixed
863/// order, as `(path, actor-id)` pairs. The actor variant is accepted by the
864/// schema and rejected (`DW0335`) by the compiler, which resolves no position for
865/// a live actor. `SoundRef::sound` carries the actor id.
866pub fn play_sound_actor_refs(c: &Campaign) -> Vec<SoundRef> {
867    let mut out = Vec::new();
868    each_effect_ref(c, &mut |stage, path, _key, eff| {
869        if let Some(actor) = eff.play_sound_actor() {
870            out.push(SoundRef {
871                stage,
872                path: format!("{path}/at/actor"),
873                sound: actor.to_string(),
874            });
875        }
876    });
877    out
878}
879
880/// Replace every inventoried string in `c` with its translation from
881/// `translations` (an l10n sidecar's `content`). Keys absent from the map are left
882/// as canonical English — but a fully-validated sidecar ([`validate_l10n`]) covers
883/// the inventory exactly, so a build only reaches here with complete coverage.
884pub fn localize(c: &mut Campaign, translations: &BTreeMap<String, String>) {
885    each_string(c, &mut |key, value| {
886        if let Some(t) = translations.get(key) {
887            *value = t.clone();
888        }
889    });
890}
891
892// ---------------------------------------------------------------------------
893// i18n v2 — translation tags and the Minecraft language-code table (spec-0029)
894// ---------------------------------------------------------------------------
895
896/// The reserved Unicode **private-use** character that delimits a *translation
897/// tag* — the in-band form that carries an inventory key alongside its canonical
898/// English from the stage docs to the text component the compiler emits it into.
899///
900/// A tagged string is `<SIGIL><key><SIGIL><english>` ([`tag`]). It exists only
901/// between [`tag_translatables`] and emission: every emitter that lowers an
902/// authored string into a **text component** splits it back apart and emits
903/// `{"translate": key, "fallback": english}` (spec-0029 §1), and every consumer
904/// that wants the human string calls [`plain`].
905///
906/// The point of an in-band tag is that a site which *fails* to do either leaks the
907/// sigil into the built tree, where the compiler's own output scan sees it and
908/// fails the build (`DW0185`). That turns "prove every authored string lands in a
909/// component" from an audit that rots into an invariant the compiler re-proves on
910/// every build, including for emitters not yet written.
911///
912/// U+E000 is the first code point of the Basic Multilingual Plane's Private Use
913/// Area: it has no character assignment, so no authored or translated content can
914/// legitimately contain it. [`validate_tr_sigil`] (`DW0183`) reserves the whole
915/// block anyway, so the tag can never be forged or shadowed by content.
916pub const TR_SIGIL: char = '\u{E000}';
917
918/// The reserved private-use range [`TR_SIGIL`] is drawn from (`U+E000..=U+F8FF`).
919/// Reserved wholesale so a near-miss cannot be authored either.
920const PUA: std::ops::RangeInclusive<char> = '\u{E000}'..='\u{F8FF}';
921
922/// Build the translation tag for `key` over its canonical English `english`.
923pub fn tag(key: &str, english: &str) -> String {
924    format!("{TR_SIGIL}{key}{TR_SIGIL}{english}")
925}
926
927/// Split a translation tag into `(key, english)`. `None` for an untagged string —
928/// a compiler-baked literal such as the default boundary message, which has no
929/// inventory key and is translated by neither v1 nor v2.
930pub fn untag(s: &str) -> Option<(&str, &str)> {
931    let rest = s.strip_prefix(TR_SIGIL)?;
932    let (key, english) = rest.split_once(TR_SIGIL)?;
933    Some((key, english))
934}
935
936/// The human string behind `s`: its English source if `s` is a translation tag,
937/// otherwise `s` unchanged. The accessor every **non-component** consumer of an
938/// authored string uses — the build manifest, the reviewer chronicles, the bot's
939/// `critical-path.json`, the generated PackTest sources. Each such site is a named
940/// exclusion in `docs/reference/compiler.md`: it is not a text component, so it
941/// cannot carry a translate key, and it is not read by a player.
942pub fn plain(s: &str) -> &str {
943    untag(s).map(|(_, e)| e).unwrap_or(s)
944}
945
946/// Whether `s` contains any reserved private-use character — i.e. whether it is,
947/// or embeds, a translation tag. The predicate the compiler's build-output scan
948/// (`DW0185`) runs over every emitted byte.
949pub fn has_tr_sigil(s: &str) -> bool {
950    s.chars().any(|c| PUA.contains(&c))
951}
952
953/// Rewrite every inventoried player-visible string in `c` into its translation tag
954/// ([`tag`]), returning the canonical-English inventory it was derived from.
955///
956/// Runs on the exact same traversal as [`inventory`] and [`localize`]
957/// ([`each_string`]), so the tagged set and the translated set are the same set by
958/// construction — the property spec-0029 keeps.
959///
960/// The campaign handed to the compiler is tagged **once**, before the plan is
961/// built; from there the tag is the compiler's only evidence that a string it is
962/// about to emit is player-visible and translatable.
963pub fn tag_translatables(c: &mut Campaign) -> BTreeMap<String, String> {
964    let mut inv = BTreeMap::new();
965    each_string(c, &mut |key, value| {
966        inv.insert(key.to_string(), value.clone());
967        *value = tag(key, value);
968    });
969    inv
970}
971
972/// Reserve the private-use block the translation tag is built from (`DW0183`): no
973/// player-visible string — authored English (the whole [`inventory`]) or any
974/// declared language's sidecar rendition — may contain a `U+E000..=U+F8FF`
975/// character. Language-independent; runs beside [`validate_marker_channel`] on
976/// every `validate` / `analyze` / `build`.
977pub fn validate_tr_sigil(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
978    let mut d = Vec::new();
979    let mut flag = |where_: String, key: &str, text: &str| {
980        let Some(bad) = text.chars().find(|ch| PUA.contains(ch)) else {
981            return;
982        };
983        d.push(Diagnostic::error(
984            codes::TR_SIGIL_RESERVED,
985            "l10n",
986            where_,
987            format!(
988                "player-visible string `{key}` contains the reserved private-use character \
989                 U+{:04X} — that block is how the compiler carries an l10n key into the text \
990                 component this string is emitted as, and it has no rendering in any \
991                 Minecraft font. Remove U+{:04X} from the line",
992                bad as u32, bad as u32
993            ),
994        ));
995    };
996    for (key, text) in inventory(c) {
997        flag(format!("#/{key}"), &key, &text);
998    }
999    for (lang, doc) in sidecars {
1000        for (key, text) in &doc.content {
1001            flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
1002        }
1003    }
1004    d
1005}
1006
1007/// Every declared-language code this build knows how to write a lang file for, in
1008/// declaration order, as `(declared code, minecraft code)`. `Err` names the first
1009/// unmapped code (`DW0184`) — a language is never silently dropped.
1010pub fn declared_mc_codes(c: &Campaign) -> Result<Vec<(String, &'static str)>, Diagnostic> {
1011    let mut out = Vec::new();
1012    for lang in &c.world.content.languages {
1013        match crate::mclang::mc_lang_code(lang) {
1014            Some(mc) => out.push((lang.clone(), mc)),
1015            None => {
1016                return Err(Diagnostic::error(
1017                    codes::LANG_CODE_UNMAPPED,
1018                    "world",
1019                    format!("/content/languages/{lang}"),
1020                    format!(
1021                        "declared language `{lang}` has no Minecraft language-file code — the \
1022                         resource pack has nowhere to write its \
1023                         `assets/delvewright/lang/<code>.json`, and the language would ship \
1024                         invisible. Use a code the pinned 1.21.11 client really loads \
1025                         (`dsl::mclang::CLIENT_LANGS`, derived from Mojang's own asset index) \
1026                         — e.g. `zh-cn`, `ja-jp`, `de-de`"
1027                    ),
1028                ));
1029            }
1030        }
1031    }
1032    Ok(out)
1033}
1034
1035/// **Translation provenance** (`DW0187` / `DW0188`): is each sidecar row still a
1036/// translation of the English it renders?
1037///
1038/// [`validate_l10n`] proves the sidecar's key SET equals the inventory's. That is
1039/// silent about whether a row still *corresponds* to its key: rewrite an authored
1040/// line and its translation is present, applied and wrong, with no key moved. The
1041/// sidecar's [`L10nDoc::source`] map closes it by recording the English each row
1042/// was translated from, so the compiler can compare instead of a human auditing.
1043///
1044/// Two findings:
1045///
1046/// * `DW0187` — a recorded source differs from the key's canonical English (the
1047///   row is stale), or names a key the sidecar does not translate at all (the
1048///   provenance itself is stale).
1049/// * `DW0188` — rows with no recorded provenance, **counted**. Those rows are
1050///   unguarded, and saying so on every run is what keeps an unadopted sidecar
1051///   from reading like a checked one. Warning tier: `source` is additive, and
1052///   this is the one-version deprecation window before it is required.
1053///
1054/// The entity display-name rule makes this more than hygiene. A name key belongs
1055/// to the first site declaring a given text, so renaming ONE body can migrate a
1056/// key to ANOTHER — the row that goes stale is not the row the author edited, and
1057/// the missing-key half of the move (`DW0180`) points somewhere else entirely.
1058pub fn validate_l10n_provenance(
1059    c: &Campaign,
1060    sidecars: &BTreeMap<String, L10nDoc>,
1061) -> Vec<Diagnostic> {
1062    let mut d = Vec::new();
1063    if c.world.content.languages.is_empty() {
1064        return d;
1065    }
1066    let inv = inventory(c);
1067    for lang in &c.world.content.languages {
1068        let Some(doc) = sidecars.get(lang) else {
1069            continue; // absent sidecar is DW0180's finding, not this one's.
1070        };
1071        for (key, was) in &doc.source {
1072            match inv.get(key) {
1073                Some(now) if now == was => {}
1074                Some(now) => d.push(Diagnostic::error(
1075                    codes::L10N_STALE,
1076                    "l10n",
1077                    format!("l10n/{lang}.json#/source/{key}"),
1078                    format!(
1079                        "`{key}` was translated from {was:?} but now reads {now:?} — the \
1080                         translation in `content` still renders the old line and would ship \
1081                         attached to the new one. Re-translate `{key}` and update its `source` \
1082                         (`tools/i18n-translate.py <campaign> --lang {lang}` does both). If a \
1083                         RENAME surprised you here: an entity display name's key belongs to the \
1084                         first body declaring that text, so renaming one body can hand its key \
1085                         to another"
1086                    ),
1087                )),
1088                None => d.push(Diagnostic::error(
1089                    codes::L10N_STALE,
1090                    "l10n",
1091                    format!("l10n/{lang}.json#/source/{key}"),
1092                    format!(
1093                        "`source` records `{key}`, which is not in the string inventory — the \
1094                         provenance is stale even if the translation is gone. Remove `{key}` \
1095                         from `source` in `l10n/{lang}.json`"
1096                    ),
1097                )),
1098            }
1099        }
1100        // Every row `source` does not cover is a row DW0187 cannot see. Report the
1101        // count: an unadopted sidecar must never look like a checked one.
1102        let unguarded = doc
1103            .content
1104            .keys()
1105            .filter(|k| !doc.source.contains_key(*k))
1106            .count();
1107        if unguarded > 0 {
1108            let total = doc.content.len();
1109            d.push(Diagnostic::warning(
1110                codes::L10N_PROVENANCE_MISSING,
1111                "l10n",
1112                format!("l10n/{lang}.json"),
1113                format!(
1114                    "{unguarded} of {total} translated rows record no `source`, so nothing can \
1115                     tell whether they still translate the English they render — an edited line \
1116                     leaves its translation present, applied and wrong, and no key moves. Run \
1117                     `tools/i18n-translate.py <campaign> --lang {lang}` to record provenance for \
1118                     the rows it already has. This warning is the one-version deprecation \
1119                     window; `source` becomes required after it"
1120                ),
1121            ));
1122        }
1123    }
1124    d
1125}
1126
1127/// Coverage + envelope validation for every declared language's l10n sidecar
1128/// (`DW0180` / `DW0181`). Language-independent: it runs on every `validate` /
1129/// `analyze` / `build`, regardless of `--lang`. Returns no diagnostics for a
1130/// campaign that declares no languages. `sidecars` is keyed by language code
1131/// (the `l10n/<code>.json` filename stem).
1132pub fn validate_l10n(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
1133    let mut d = Vec::new();
1134    let declared = &c.world.content.languages;
1135    if declared.is_empty() {
1136        return d;
1137    }
1138    // A sidecar must carry every key of the inventory (`DW0180`) and no key
1139    // outside it (`DW0181`).
1140    let inv = inventory(c);
1141    let required_keys: BTreeSet<&str> = inv.keys().map(String::as_str).collect();
1142    let inv_keys: BTreeSet<&str> = required_keys.clone();
1143    let campaign_id = c.world.campaign_id.as_str();
1144
1145    for lang in declared {
1146        if lang == CANONICAL_LANG {
1147            d.push(Diagnostic::error(
1148                codes::L10N_MISSING,
1149                "l10n",
1150                format!("/world/languages/{lang}"),
1151                format!(
1152                    "`{lang}` is the canonical language and must not be declared in \
1153                     `world.languages` — English is implicit; remove `{lang}` from the list"
1154                ),
1155            ));
1156            continue;
1157        }
1158        let Some(doc) = sidecars.get(lang) else {
1159            d.push(Diagnostic::error(
1160                codes::L10N_MISSING,
1161                "l10n",
1162                format!("l10n/{lang}.json"),
1163                format!(
1164                    "declared language `{lang}` has no `l10n/{lang}.json` sidecar — add the \
1165                     sidecar (a full key→translation map), or remove `{lang}` from \
1166                     `world.languages`"
1167                ),
1168            ));
1169            continue;
1170        };
1171        // Envelope consistency (folded into DW0180 — the sidecar does not correctly
1172        // cover the declared language).
1173        if doc.campaign_id.as_str() != campaign_id {
1174            d.push(Diagnostic::error(
1175                codes::L10N_MISSING,
1176                "l10n",
1177                format!("l10n/{lang}.json"),
1178                format!(
1179                    "sidecar `campaign_id` `{}` differs from the campaign's `{campaign_id}` — set \
1180                     the sidecar's `campaign_id` to `{campaign_id}`",
1181                    doc.campaign_id
1182                ),
1183            ));
1184        }
1185        if doc.lang != *lang {
1186            d.push(Diagnostic::error(
1187                codes::L10N_MISSING,
1188                "l10n",
1189                format!("l10n/{lang}.json"),
1190                format!(
1191                    "sidecar `lang` field `{}` differs from filename code `{lang}` — set the \
1192                     sidecar's `lang` to `{lang}` (it must match the `l10n/{lang}.json` filename)",
1193                    doc.lang
1194                ),
1195            ));
1196        }
1197        if doc.dsl_version != DSL_VERSION {
1198            d.push(Diagnostic::error(
1199                codes::L10N_MISSING,
1200                "l10n",
1201                format!("l10n/{lang}.json"),
1202                format!(
1203                    "sidecar declares dsl_version `{}` — set it to `{DSL_VERSION}`, the one \
1204                     number this engine accepts, matching the stage documents",
1205                    doc.dsl_version,
1206                ),
1207            ));
1208        }
1209        // Coverage: missing (DW0180) against what this campaign's version owes,
1210        // orphan (DW0181) against the whole inventory.
1211        let side_keys: BTreeSet<&str> = doc.content.keys().map(String::as_str).collect();
1212        for missing in required_keys.difference(&side_keys) {
1213            d.push(Diagnostic::error(
1214                codes::L10N_MISSING,
1215                "l10n",
1216                format!("l10n/{lang}.json"),
1217                format!(
1218                    "sidecar is missing a translation for inventory key `{missing}` — add \
1219                     `{missing}` to `l10n/{lang}.json` (coverage must be exact)"
1220                ),
1221            ));
1222        }
1223        for orphan in side_keys.difference(&inv_keys) {
1224            d.push(Diagnostic::error(
1225                codes::L10N_ORPHAN,
1226                "l10n",
1227                format!("l10n/{lang}.json#/content/{orphan}"),
1228                format!(
1229                    "orphan key `{orphan}` is not in the string inventory — remove it from \
1230                     `l10n/{lang}.json` (the sidecar must cover exactly the inventory, no extras)"
1231                ),
1232            ));
1233        }
1234    }
1235    d
1236}