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//! These are the keys **within one campaign** — what a sidecar answers, what
23//! `DW0180` counts, what `delvec l10n-inventory` hands a translator. What leaves
24//! the delve is each of them under that delve's own [pack namespace]
25//! (`delve.<campaign_id>.`, [`pack_key`]), because a client merges every applied
26//! resource pack into ONE language table and a campaign-relative key in there is
27//! a key some other delve answers. See [`pack_namespace`].
28//!
29//! A delve's pack writes into a second client-global space with the same property
30//! — the texture space its baked skins land in — so that namespace lives here too
31//! ([`pack_texture_id`], [`namespace_skin_textures`]), beside the one it argues
32//! from. What leaves a delve is namespaced in one place or in none.
33//!
34//! [pack namespace]: pack_namespace
35//!
36//! | Key | Source string |
37//! |-----|---------------|
38//! | `world.title` | stage-1 `content.title` |
39//! | `area.<area>.name` | each stage-1 area `name` |
40//! | `class.<class>.name` / `.blurb` | each stage-3 class |
41//! | `class.<class>.kit.<i>.name` | a kit item's display `name` (only if set) |
42//! | `npc.<npc>.name` | each stage-2 NPC `name` (see *entity display names* below) |
43//! | `actor.<actor>.name` | each stage-5 actor `name` (v0.6, only if set; see below) |
44//! | `quest.<quest>.goal` | each stage-4 planned-quest `goal` |
45//! | `obj.<quest>.<obj>.title` / `.hint` | a stage-5 objective's `title`/`hint` (only if set) |
46//! | `obj.<quest>.<obj>.missing_item_hint` | a stage-5 `interact`'s `missing_item_hint` (v0.7, only if set) |
47//! | `obj.<quest>.<obj>.item_name` | a stage-5 `collect`'s `item_name` (v0.8, only if set) |
48//! | `dlg.<npc>.<node>.text` | each stage-6 dialogue node `text` |
49//! | `dlg.<npc>.<node>.opt.<i>.label` | each dialogue option `label` |
50//! | `dlg.<npc>.<node>.opt.<i>.tooltip` | that option's hover `tooltip` (v0.8, only if set) |
51//! | `wave.<wave>.mob.<i>.name` | a wave mob's custom `name` (only if set) |
52//! | `wave.<wave>.mob.<i>.drop.<n>.name` | a declared quest-item drop's display `name` (v0.9, only if set) |
53//! | `actor.<actor>.drop.<n>.name` | an actor's declared quest-item drop `name` (v0.9, only if set) |
54//! | `fx.…​.narrate` / `fx.…​.give` | a `narrate` line / named `give-item` in an effect list |
55//! | `fx.…​.rest_prompt` / `.rest_label` / `.save_label` | a `bonfire`'s authored rest-dialog strings (v0.8, only if set) |
56//! | `fx.…​.rest_tooltip` / `.save_tooltip` | a `bonfire` button's hover tooltip (spec-0078, only if set) |
57//! | `fx.…​.sealed_hint` | a `close-gate`'s authored answer to a right-click on the seal (v0.8, only if set) |
58//! | `lethal.<volume>.message` | a stage-5 lethal volume's death wording (v0.10) |
59//! | `state.<datum>.name` | a runtime datum's player-visible name — a currency (v0.10) |
60//! | `shop.<shop>.title` | a stage-5 shop dialog's title (v0.10) |
61//! | `shop.<shop>.offer.<i>.label` | a shop button's caption (v0.10) |
62//! | `shop.<shop>.offer.<i>.tooltip` | a shop button's hover tooltip (v0.10) |
63//! | `stake.<stake>.collected` | what collecting a recovery stake says (v0.10) |
64//!
65//! ## Nested effects (DSL v0.6)
66//!
67//! Effect strings nested inside a `sequence` step or a lifecycle bundle
68//! (`on_respawn`/`on_caught`/`on_arrive`) are player-visible too, so they are
69//! inventoried under **position-derived** child keys: the parent effect's `fx.…`
70//! key, then a stable segment ([`crate::QuestEffect::nested_effect_lists_keyed_mut`])
71//! — `seq.<step>` for a sequence step, `respawn`/`caught`/`arrive` for the bundles —
72//! then the effect's index in that list, then the leaf (`.narrate`/`.give`).
73//! Example: a narrate in sequence step 1, effect 0 of `on_objective_complete`
74//! effect 0 → `fx.<quest>.oc.<obj>.0.seq.1.0.narrate`. Nesting is arbitrary-depth
75//! (a `move-actor.on_arrive` inside a `sequence` step nests both segments). Keys are
76//! purely position-derived → deterministic and stable across builds (ADR-0006).
77//!
78//! ## Entity display names are keyed by their TEXT, not by their site
79//!
80//! An NPC (`npc.<npc>.name`) and a scripted actor (`actor.<actor>.name`) are two
81//! DSL surfaces for the same thing a player reads: a nameplate over a body. One
82//! character routinely occupies both — a stage-2 NPC that stands and talks, plus
83//! one actor puppet per cutscene pose it is staged in. If each site owned its own
84//! key, a translator would be asked for `Polyphemus` five times and could answer
85//! differently each time, and the giant's name would **change as he walked into a
86//! cutscene** — a worse defect than the untranslated one, and an authored one.
87//!
88//! So the key of an entity display name is decided by its **canonical English
89//! text**: the first site (in this traversal's fixed order — NPCs before actors)
90//! declaring a given name owns the key, and every later site carrying the
91//! byte-identical name emits that same key. The inventory therefore asks for each
92//! distinct name exactly once, and two bodies a player reads as one character
93//! cannot render as two.
94//!
95//! Scope is deliberately the **entity display-name class only** (`npc.*.name`,
96//! `actor.*.name`). Prose — a narrate line, a dialogue label, an objective title —
97//! is context-bound and keeps one key per site: two English strings that happen to
98//! coincide may legitimately need different renderings. Wave-mob names
99//! (`wave.*.mob.*.name`) are the same shape and are **not** merged here: that is a
100//! generalization beyond the finding this rule closes, and it is an owner call
101//! because it retires keys live campaigns already translate.
102//!
103//! Player-visible strings only. Deliberately **excluded** (authoring context the
104//! player never sees, so translating them is pointless and out of scope): world
105//! `theme`/`premise`, NPC `persona` fields, persona `relationships`.
106
107use crate::Verb;
108use std::collections::{BTreeMap, BTreeSet};
109
110use schemars::JsonSchema;
111use serde::{Deserialize, Serialize};
112
113use crate::diagnostic::{Diagnostic, codes};
114use crate::envelope::{Campaign, DSL_VERSION};
115use crate::ids::CampaignId;
116use crate::{NarrateStyle, QuestEffect};
117
118/// Walk the player-visible strings of a single quest effect (DSL v0.4): a
119/// `narrate` line and a named `give-item`'s display name. `keybase` is the
120/// effect's stable position-derived key prefix.
121fn effect_strings(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
122    match &mut eff.verb {
123        Verb::Narrate { text, .. } => f(&format!("{keybase}.narrate"), text),
124        Verb::GiveItem { name: Some(n), .. } => f(&format!("{keybase}.give"), n),
125        // spec-0016 §1: the bonfire's rest dialog is
126        // read by the player like any other on-screen line, so its authored
127        // strings translate like any other. Unauthored fields are absent from the
128        // inventory — the compiler bakes its canonical English, exactly as
129        // `world.boundary.message` does.
130        Verb::Bonfire {
131            prompt,
132            rest_label,
133            save_label,
134            rest_tooltip,
135            save_tooltip,
136            ..
137        } => {
138            if let Some(p) = prompt.as_mut() {
139                f(&format!("{keybase}.rest_prompt"), p);
140            }
141            if let Some(r) = rest_label.as_mut() {
142                f(&format!("{keybase}.rest_label"), r);
143            }
144            if let Some(s) = save_label.as_mut() {
145                f(&format!("{keybase}.save_label"), s);
146            }
147            // spec-0078: a button's hover tooltip is read like its label.
148            if let Some(t) = rest_tooltip.as_mut() {
149                f(&format!("{keybase}.rest_tooltip"), t);
150            }
151            if let Some(t) = save_tooltip.as_mut() {
152                f(&format!("{keybase}.save_tooltip"), t);
153            }
154        }
155        // DSL v0.8: what a sealed gate answers when the party right-clicks it. Read
156        // off the actionbar exactly like a `narrate`, so it translates like one. An
157        // unauthored hint is absent from the inventory — the compiler bakes its
158        // canonical English, exactly as `world.boundary.message` does.
159        Verb::CloseGate {
160            sealed_hint: Some(h),
161            ..
162        } => f(&format!("{keybase}.sealed_hint"), h),
163        _ => {}
164    }
165}
166
167/// Walk the player-visible strings of `eff` **and every effect nested inside it**
168/// (DSL v0.6): a `narrate`/`give-item` inside a `sequence` step or an
169/// `on_respawn`/`on_caught`/`on_arrive` bundle is player-visible and must enter the
170/// inventory (and be localized on the emission path), else it ships English-only in
171/// a translated build. Child keys extend `keybase` with the effect's stable key
172/// segment ([`QuestEffect::nested_effect_lists_keyed_mut`]) and the effect's index
173/// within that list, e.g. `<keybase>.seq.<step>.<j>.narrate` for a narrate in
174/// sequence step `<step>`, effect `<j>`. Deterministic and stable across builds.
175fn effect_strings_deep(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
176    effect_strings(eff, keybase, f);
177    for (seg, list) in eff.nested_effect_lists_keyed_mut() {
178        for (j, inner) in list.iter_mut().enumerate() {
179            effect_strings_deep(inner, &format!("{keybase}.{seg}.{j}"), f);
180        }
181    }
182}
183
184/// The implicit, always-canonical language. Never appears in `world.languages`
185/// and never has a sidecar; `delvec build --lang en` emits the pure-English delve.
186pub const CANONICAL_LANG: &str = "en";
187
188/// The reserved sigil opening the compiler's machine-readable **completion-marker**
189/// channel — `[dw:complete <campaign_id> <token>]`, the only evidence the
190/// validation bot accepts that an objective (or the campaign) actually completed.
191/// The channel rides chat, so any authored or translated player-visible string
192/// carrying this sigil could forge a completion and make a critical-path step pass
193/// hollow. [`validate_marker_channel`] (`DW0182`) reserves it, making the collision
194/// structurally impossible instead of merely implausible.
195pub const MARKER_SIGIL: &str = "[dw:complete";
196
197/// Reserve the machine completion-marker channel (`DW0182`): no player-visible
198/// string — authored English (the whole [`inventory`]) or any declared language's
199/// sidecar rendition — may contain [`MARKER_SIGIL`]. Language-independent; runs on
200/// every `validate` / `analyze` / `build`. Checks translations too: a translator
201/// (LLM or human) copying the sigil through is exactly the forgery this closes.
202pub fn validate_marker_channel(
203    c: &Campaign,
204    sidecars: &BTreeMap<String, L10nDoc>,
205) -> Vec<Diagnostic> {
206    let mut d = Vec::new();
207    let mut flag = |where_: String, key: &str, text: &str| {
208        if text.contains(MARKER_SIGIL) {
209            d.push(Diagnostic::error(
210                codes::MARKER_RESERVED,
211                "l10n",
212                where_,
213                format!(
214                    "player-visible string `{key}` contains the reserved completion-marker \
215                     sigil `{MARKER_SIGIL}` — that chat sequence is the validation bot's \
216                     completion oracle, and authored text carrying it could forge a passing \
217                     critical-path step. Reword the line to drop `{MARKER_SIGIL}`"
218                ),
219            ));
220        }
221    };
222    for (key, text) in inventory(c) {
223        flag(format!("#/{key}"), &key, &text);
224    }
225    for (lang, doc) in sidecars {
226        for (key, text) in &doc.content {
227            flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
228        }
229    }
230    d
231}
232
233/// The kind marker on an l10n sidecar envelope (`"l10n"`), analogous to the stage
234/// marker on a stage document.
235#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236#[serde(rename_all = "kebab-case")]
237pub enum L10nKind {
238    /// A localization sidecar (`l10n/<code>.json`).
239    L10n,
240}
241
242/// An l10n sidecar document: `{ dsl_version, campaign_id, kind: "l10n", lang,
243/// content, source }`, mirroring the stage-doc envelope style. `content` is a flat
244/// map of [inventory](crate::l10n::inventory) key → translated string; `source`
245/// records the canonical English each of those translations was made **from**.
246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
247#[serde(deny_unknown_fields)]
248pub struct L10nDoc {
249    /// DSL version string (same versioning as the stage docs).
250    pub dsl_version: String,
251    /// Owning campaign id (must match the stage docs).
252    pub campaign_id: CampaignId,
253    /// Document kind marker (`l10n`).
254    pub kind: L10nKind,
255    /// The BCP-47-style language code this sidecar translates into (must match the
256    /// `l10n/<code>.json` filename and appear in `world.languages`).
257    pub lang: String,
258    /// Flat map of inventory key → translated string.
259    pub content: BTreeMap<String, String>,
260    /// **Translation provenance**: inventory key → the canonical English that key
261    /// held when its [`Self::content`] row was written.
262    ///
263    /// Coverage validation proves the sidecar has a row for every key
264    /// (`DW0180`/`DW0181`), which is a statement about key SETS and says nothing
265    /// about whether a row still corresponds to the English it renders. Edit an
266    /// authored line and its translation is stale, present, applied, and wrong —
267    /// and nothing in the key sets moved. `source` is what makes that
268    /// **detectable** ([`validate_l10n_provenance`], `DW0187`) instead of audited.
269    ///
270    /// This is load-bearing for entity display names in particular, because their
271    /// key is owned by the first site declaring a given text (see the module
272    /// header): renaming ONE body can migrate a key's ownership to ANOTHER body,
273    /// so the row that goes wrong is not the row the author touched.
274    ///
275    /// Optional in the format — an older sidecar parses unchanged and simply
276    /// carries no provenance, which `DW0188` reports as an unguarded row count on
277    /// every run rather than letting it pass in silence.
278    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
279    pub source: BTreeMap<String, String>,
280}
281
282/// The local part of a type-prefixed id: the segment after the first `/` (kebab
283/// preserved). `npc/keeper` → `keeper`. Ids without a `/` pass through unchanged.
284fn local(id: &str) -> &str {
285    id.split_once('/').map(|(_, r)| r).unwrap_or(id)
286}
287
288/// The local part of a type-prefixed DSL id, exactly as the key scheme derives it
289/// (`npc/keeper` → `keeper`). Public so a consumer that pairs inventory keys back
290/// with their source objects (`delvec l10n-inventory`, matching `dlg.<npc>.…` keys
291/// to the NPC that speaks them) derives the same segment the keys are built from.
292pub fn local_id(id: &str) -> &str {
293    local(id)
294}
295
296/// Walk every player-visible string in `c` in a fixed, deterministic order,
297/// invoking `f(key, &mut value)` for each. The single traversal shared by
298/// [`inventory`] and [`localize`] — they cannot drift.
299pub fn each_string(c: &mut Campaign, f: &mut dyn FnMut(&str, &mut String)) {
300    // Entity display names (a nameplate over a body) are keyed by their canonical
301    // English TEXT, not by their declaration site: canonical English → owning key.
302    // See the module header — one character routinely has one NPC identity and
303    // several actor puppets, and a per-site key would let its name be translated
304    // several ways. Filled in traversal order, so the NPC identity always owns the
305    // key and the puppets follow it.
306    let mut entity_names: BTreeMap<String, String> = BTreeMap::new();
307    // Stage 1 — world title + area names.
308    f("world.title", &mut c.world.content.title);
309    for area in &mut c.world.content.areas {
310        let key = format!("area.{}.name", local(area.id.as_str()));
311        f(&key, &mut area.name);
312    }
313    // Stage 1 — boundary return message (v0.6, only when authored). The compiler's
314    // default English is baked at emit time, so it is not inventoried; an authored
315    // message is translated like every other player-facing string.
316    if let Some(b) = c.world.content.boundary.as_mut()
317        && let Some(msg) = b.message.as_mut()
318    {
319        f("world.boundary.message", msg);
320    }
321    // Stage 1 — campaign outro (v0.6, only when authored): the closing line on the
322    // completion advancement. Unauthored, the emitter falls back to the finale
323    // quest's `goal`, which is inventoried in its own right — so the last sentence
324    // of a delve is campaign-derived and translated either way.
325    if let Some(outro) = c.world.content.outro.as_mut() {
326        f("world.outro", outro);
327    }
328    // Stage 3 — class names/blurbs + optional kit item display names.
329    for class in &mut c.classes.content.classes {
330        let cl = local(class.id.as_str()).to_string();
331        f(&format!("class.{cl}.name"), &mut class.name);
332        f(&format!("class.{cl}.blurb"), &mut class.blurb);
333        for (i, item) in class.kit.iter_mut().enumerate() {
334            if let Some(name) = item.name.as_mut() {
335                f(&format!("class.{cl}.kit.{i}.name"), name);
336            }
337        }
338    }
339    // Stage 2 — NPC names. First in the entity display-name class, so an NPC
340    // identity owns the key every actor puppet portraying it shares.
341    for npc in &mut c.npcs.content.npcs {
342        let key = entity_name_key(
343            &mut entity_names,
344            &npc.name,
345            format!("npc.{}.name", local(npc.id.as_str())),
346        );
347        f(&key, &mut npc.name);
348    }
349    // Stage 4 — quest goals.
350    for q in &mut c.quest_plan.content.quests {
351        let key = format!("quest.{}.goal", local(q.id.as_str()));
352        f(&key, &mut q.goal);
353    }
354    // Stage 5 — objective titles/hints (when set).
355    for q in &mut c.quests.content.quests {
356        let ql = local(q.id.as_str()).to_string();
357        for o in &mut q.objectives {
358            let ol = local(o.id().as_str()).to_string();
359            if let Some(title) = o.title_mut().as_mut() {
360                f(&format!("obj.{ql}.{ol}.title"), title);
361            }
362            if let Some(hint) = o.hint_mut().as_mut() {
363                f(&format!("obj.{ql}.{ol}.hint"), hint);
364            }
365            // Stage 5 — v0.7 `interact.missing_item_hint`: narrated in chat to the
366            // player who clicks without the required item in hand, so it is as
367            // player-visible as `hint` and translates like it. Absent on every
368            // pre-0.7 objective → inventory unchanged.
369            if let crate::Objective::Interact {
370                missing_item_hint: Some(m),
371                ..
372            } = o
373            {
374                f(&format!("obj.{ql}.{ol}.missing_item_hint"), m);
375            }
376            // Stage 5 — v0.8 `collect.item_name`: the display name the
377            // collected item carries as a `custom_name` component. A player reads
378            // it off the stack in the barrel and off their own hotbar, so it is as
379            // player-visible as a `title` and translates like one. Absent on every
380            // pre-0.8 objective → inventory unchanged.
381            if let crate::Objective::Collect {
382                item_name: Some(n), ..
383            } = o
384            {
385                f(&format!("obj.{ql}.{ol}.item_name"), n);
386            }
387        }
388        // Stage 5 — v0.7 cast-ledger bark lines (spec-0020). Barks are spoken
389        // in-game exactly like narrate text, so they translate like it too.
390        // `doing` is deliberately NOT inventoried: it is authoring context for
391        // the dialogue stage, never shown to a player. (`cast` is a BTreeMap and
392        // the placement list is ordered, so the traversal stays deterministic.)
393        for (npc, entry) in &mut q.cast {
394            let np = local(npc.as_str()).to_string();
395            for (b, p) in entry.placements_mut().into_iter().enumerate() {
396                let Some(crate::CastDialogue::Barks(pool)) = p.dialogue.as_mut() else {
397                    continue;
398                };
399                for (i, line) in pool.barks.iter_mut().enumerate() {
400                    f(&format!("cast.{ql}.{np}.{b}.bark.{i}"), line);
401                }
402            }
403        }
404    }
405    // Stage 6 — dialogue node text + option labels.
406    for tree in &mut c.dialogue.content.dialogues {
407        let np = local(tree.npc.as_str()).to_string();
408        for node in &mut tree.nodes {
409            let nd = local(node.id.as_str()).to_string();
410            f(&format!("dlg.{np}.{nd}.text"), &mut node.text);
411            for (i, opt) in node.options.iter_mut().enumerate() {
412                f(&format!("dlg.{np}.{nd}.opt.{i}.label"), &mut opt.label);
413                // v0.8: the button's hover tooltip. A player reads it exactly as
414                // they read the caption, so it translates exactly like one; an
415                // unauthored tooltip is absent from the inventory (no key, no
416                // coverage obligation), like every other `only if set` string.
417                if let Some(tip) = opt.tooltip.as_mut() {
418                    f(&format!("dlg.{np}.{nd}.opt.{i}.tooltip"), tip);
419                }
420            }
421        }
422    }
423    // Stage 5 — wave mob custom names (when set).
424    for w in &mut c.quests.content.waves {
425        let wl = local(w.id.as_str()).to_string();
426        for (i, mob) in w.mobs.iter_mut().enumerate() {
427            if let Some(name) = mob.name.as_mut() {
428                f(&format!("wave.{wl}.mob.{i}.name"), name);
429            }
430            // Stage 5 — v0.9 declared quest-item drops. The name
431            // rides the dropped stack's `custom_name`, so the player reads it off
432            // the ground and off their own hotbar: as player-visible as a wave
433            // mob's own name, and translated like one.
434            for (n, dr) in mob.drops.iter_mut().enumerate() {
435                if let Some(name) = dr.name_mut() {
436                    f(&format!("wave.{wl}.mob.{i}.drop.{n}.name"), name);
437                }
438            }
439        }
440        // Stage 5 — a wave's stated health-bar title (DSL v0.31, spec-0073). A
441        // DERIVED title is not a string of its own: it is the one mob entry's
442        // name, emitted under that name's key, so the character is translated
443        // once and the bar cannot call it something else.
444        if let Some(t) = w.health_bar.as_mut().and_then(|b| b.title.as_mut()) {
445            f(&format!("wave.{wl}.health_bar.title"), t);
446        }
447    }
448    // Stage 5 — actors: the nameplate over the puppet, then its v0.9 drops,
449    // keyed off the actor id exactly as a wave mob's drop is keyed off its
450    // wave.
451    for a in &mut c.quests.content.actors {
452        let al = local(a.id.as_str()).to_string();
453        // The puppet's own name (v0.6 `actors[].name`). Player-visible in every
454        // frame it stands in — a nameplate and, for a cutscene mannequin, the
455        // label the party reads while the scene plays — so it is as translatable
456        // as the stage-2 NPC name it usually duplicates, and shares that NPC's key
457        // when the two texts are identical (module header).
458        //
459        // `ACTOR_NAME_ENTRY` carries the whole fence: the field is v0.6 but the
460        // walk only reached it in the 0.10 era, so the coverage obligation is
461        // 0.10's and not v0.6's.
462        if let Some(name) = a.name.as_mut() {
463            let key = entity_name_key(&mut entity_names, name, format!("actor.{al}.name"));
464            f(&key, name);
465        }
466        for (n, dr) in a.drops.iter_mut().enumerate() {
467            if let Some(name) = dr.name_mut() {
468                f(&format!("actor.{al}.drop.{n}.name"), name);
469            }
470        }
471        // An actor's stated health-bar title (spec-0073); a derived one is the
472        // actor's `name`, under whatever key that name is inventoried.
473        if let Some(t) = a.health_bar.as_mut().and_then(|b| b.title.as_mut()) {
474            f(&format!("actor.{al}.health_bar.title"), t);
475        }
476    }
477    // Stage 5 — loot item custom names (spec-0021), keyed like a class kit
478    // item's name so a named prop in a chest translates like any other.
479    for l in &mut c.quests.content.loot {
480        let ll = local(l.id.as_str()).to_string();
481        for (i, item) in l.items.iter_mut().enumerate() {
482            if let Some(name) = item.name.as_mut() {
483                f(&format!("loot.{ll}.item.{i}.name"), name);
484            }
485        }
486    }
487    // Stage 5 — lethal volumes (v0.10, spec-0031): the line the volume says as it
488    // kills. As player-visible as a narrate, and read at the worst possible moment
489    // to be reading a raw key, so it is inventoried like any other authored line.
490    for v in &mut c.quests.content.lethal_volumes {
491        let vl = local(v.id.as_str()).to_string();
492        f(&format!("lethal.{vl}.message"), &mut v.message);
493    }
494    // Stage 5 — a runtime datum's player-visible name (v0.10, spec-0032). A named
495    // datum is a currency: the engine states `<name>: <value>` on the holder's
496    // action bar on every write, so the name is read as often as any narrate.
497    for st in &mut c.quests.content.state {
498        let sl = local(st.id.as_str()).to_string();
499        if let Some(name) = st.name.as_mut() {
500            f(&format!("state.{sl}.name"), name);
501        }
502    }
503    // Stage 5 — shops (v0.10, spec-0032): the dialog's title, and each button's
504    // caption and tooltip. Keyed exactly as a dialogue node's label/tooltip are,
505    // because they are the same two components of the same vanilla button codec.
506    for sh in &mut c.quests.content.shops {
507        let hl = local(sh.id.as_str()).to_string();
508        f(&format!("shop.{hl}.title"), &mut sh.title);
509        for (i, off) in sh.offers.iter_mut().enumerate() {
510            f(&format!("shop.{hl}.offer.{i}.label"), &mut off.label);
511            if let Some(t) = off.tooltip.as_mut() {
512                f(&format!("shop.{hl}.offer.{i}.tooltip"), t);
513            }
514        }
515    }
516    // Stage 5 — recovery stakes (v0.10, spec-0032): the line a collection says.
517    for st in &mut c.quests.content.stakes {
518        let sl = local(st.id.as_str()).to_string();
519        f(&format!("stake.{sl}.collected"), &mut st.collected_message);
520    }
521    // v0.4 effect strings — `narrate` text, a named `give-item`, a bonfire's rest
522    // dialog, a seal's answer — over **every** root emission can lower an effect
523    // from, not just the quests stage's three ([`effect_roots_mut`]).
524    // Nesting inside each root is descended by `effect_strings_deep`, so a narrate
525    // in a `sequence` step of a trap payload is inventoried like any other.
526    for (_stage, _path, keybase, eff) in effect_roots_mut(c) {
527        effect_strings_deep(eff, &keybase, f);
528    }
529}
530
531/// The key an entity display name is inventoried under: the key already claimed by
532/// an identical name earlier in the traversal, or `own` if this site is the first
533/// to carry that text (in which case it claims it for every later site).
534///
535/// The lookup is on the string as authored, captured **before** `f` may rewrite it
536/// ([`localize`] swaps the NPC's name to the target language, and the actor puppets
537/// that follow are still English at the moment they are looked up).
538fn entity_name_key(claimed: &mut BTreeMap<String, String>, text: &str, own: String) -> String {
539    claimed.entry(text.to_string()).or_insert(own).clone()
540}
541
542/// The authoritative key → canonical-English inventory derived from the stage
543/// docs. Deterministic (keys are unique and the traversal order is fixed).
544///
545/// # Widening this is an ERROR-tier obligation on every existing campaign
546///
547/// This walk consults the campaign document and **never its `dsl_version`**.
548/// For the surface itself that is right — a campaign at 0.6.0 cannot use a 0.9
549/// surface, so those keys simply never appear — but it has a consequence worth
550/// stating, and one already paid for twice — by the widenings of
551/// [`each_string`] onto `traps[].payload` and onto `on_respawn` bundles:
552///
553/// **when a widening reaches strings that OLDER surfaces already emitted**, the
554/// inventory grows for campaigns of every declared version at once, and
555/// [`L10N_MISSING`](crate::codes::L10N_MISSING) (`DW0180`) is
556/// `Diagnostic::error`. A campaign that was complete and green stops building
557/// on the next engine, with no deprecation window and nothing in its own
558/// document changed.
559///
560/// Measured 2026-08-08 on `nobodys-cave-island` (sidecar `dsl_version` 0.6.0):
561/// removing one key — the shape a widening creates — exits 1 immediately with
562/// `DW0180 [error]`.
563///
564/// **The asymmetry is the finding, and it is with this module's own siblings.**
565/// One comparable obligation on existing content takes a warn-first window and
566/// says so in its own text: `DW0188` (translation provenance, in
567/// [`validate_l10n_provenance`] right here). Coverage does not. Whether it should is an owner call —
568/// changing a check's tier is never a mechanical change (CLAUDE.md) — so this
569/// records the measurement rather than acting on it.
570///
571/// Whichever way it is decided, the widening PR is where it has to be decided:
572/// adoption rounds for every active campaign belong in the same milestone as
573/// the `dsl_version` that creates the obligation, and a widening that skips the
574/// version bump creates the obligation with no milestone at all.
575pub fn inventory(c: &Campaign) -> BTreeMap<String, String> {
576    let mut c2 = c.clone();
577    let mut out = BTreeMap::new();
578    each_string(&mut c2, &mut |key, value| {
579        out.insert(key.to_string(), value.clone());
580    });
581    out
582}
583
584/// The NPC an inventory key belongs to (its **local** id), when the key scheme
585/// encodes one: `dlg.<npc>.…` (that NPC's dialogue tree — `.text` is the NPC's own
586/// line, `.opt.<i>.label` the player's reply *within* it), `npc.<npc>.name`, and
587/// `cast.<quest>.<npc>.…` (a v0.7 bark line — the NPC's own murmured speech, so a
588/// translator gets the same persona context a dialogue line gets).
589/// Returns `None` for every other key kind.
590///
591/// Lives beside [`each_string`] — the traversal that *defines* the key scheme — so
592/// the two cannot drift silently (a CLI test asserts every speaker derived from a
593/// real campaign's inventory resolves to a declared NPC). Consumed by
594/// `delvec l10n-inventory`, which hands a translator the speaking character's
595/// persona (`speech_style` above all) alongside the English line.
596pub fn key_speaker(key: &str) -> Option<&str> {
597    let (kind, rest) = key.split_once('.')?;
598    match kind {
599        "dlg" | "npc" => rest.split('.').next(),
600        // `cast.<quest>.<npc>.<branch>.bark.<i>` — the npc is the second segment.
601        "cast" => rest.split('.').nth(1),
602        _ => None,
603    }
604}
605
606/// What kind of player-facing text an inventory key holds — the class a writer
607/// (or a transcreating model) needs before the English, because each class has
608/// its own job (`docs/reference/game-writing.md` §1–§2).
609///
610/// Derived from the key alone, beside [`key_speaker`] and the traversal that
611/// defines the key scheme ([`each_string`]); a CLI test asserts every key of a
612/// real campaign's inventory resolves to a kind, so a key the scheme grows that
613/// this function does not know is a red test rather than an unlabelled row.
614#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
615#[serde(rename_all = "kebab-case")]
616pub enum TextKind {
617    /// A proper name on a body, place, class, counter or the world itself.
618    Name,
619    /// A heading: a health bar's or shop dialog's title, the world title.
620    Title,
621    /// A class blurb: what the role is.
622    Description,
623    /// An objective title or hint, or a quest goal: tells the player what to do.
624    Objective,
625    /// A message shown when an action fails: says what is wrong and what fixes it.
626    Refusal,
627    /// An NPC's line in a dialogue tree.
628    Dialogue,
629    /// An NPC's murmured line in passing (a cast bark).
630    Bark,
631    /// The caption on a fixed-width button: the player's own words.
632    OptionLabel,
633    /// The hover text of a dialog button (a dialogue option, or a bonfire's rest
634    /// or save): the consequence of choosing it.
635    ButtonTooltip,
636    /// An item's display name.
637    ItemName,
638    /// The hover text of a shop offer: what the item does.
639    ItemTooltip,
640    /// A dialog's prompt (a bonfire's rest question).
641    Prompt,
642    /// Narration in chat, on screen or on the action bar.
643    Narration,
644}
645
646/// The [`TextKind`] of an inventory key, or `None` for a key the scheme does not
647/// define. Pure on the key, like [`key_speaker`].
648pub fn key_kind(key: &str) -> Option<TextKind> {
649    use TextKind::*;
650    let segs: Vec<&str> = key.split('.').collect();
651    let first = *segs.first()?;
652    let last = *segs.last()?;
653    let n = segs.len();
654    Some(match first {
655        "world" => match key {
656            "world.title" => Title,
657            "world.boundary.message" => Refusal,
658            "world.outro" => Narration,
659            _ => return None,
660        },
661        "area" | "npc" | "state" if n == 3 && last == "name" => Name,
662        "class" => match (n, last) {
663            (3, "name") => Name,
664            (3, "blurb") => Description,
665            (5, "name") if segs[2] == "kit" => ItemName,
666            _ => return None,
667        },
668        "quest" if n == 3 && last == "goal" => Objective,
669        "obj" if n == 4 => match last {
670            "title" | "hint" => Objective,
671            "missing_item_hint" => Refusal,
672            "item_name" => ItemName,
673            _ => return None,
674        },
675        "cast" if n == 6 && segs[4] == "bark" => Bark,
676        "dlg" => match (n, last) {
677            (4, "text") => Dialogue,
678            (6, "label") if segs[3] == "opt" => OptionLabel,
679            (6, "tooltip") if segs[3] == "opt" => ButtonTooltip,
680            _ => return None,
681        },
682        "wave" | "actor" => match last {
683            "name" if segs.contains(&"drop") => ItemName,
684            "name" => Name,
685            "title" if segs[n - 2] == "health_bar" => Title,
686            _ => return None,
687        },
688        "loot" if n == 5 && last == "name" => ItemName,
689        "lethal" if n == 3 && last == "message" => Narration,
690        "stake" if n == 3 && last == "collected" => Narration,
691        "shop" => match (n, last) {
692            (3, "title") => Title,
693            (5, "label") => OptionLabel,
694            (5, "tooltip") => ItemTooltip,
695            _ => return None,
696        },
697        "fx" => match last {
698            "narrate" => Narration,
699            "give" => ItemName,
700            "rest_prompt" => Prompt,
701            "rest_label" | "save_label" => OptionLabel,
702            // spec-0078: a bonfire's buttons carry hover text like every other
703            // dialog button.
704            "rest_tooltip" | "save_tooltip" => ButtonTooltip,
705            "sealed_hint" => Refusal,
706            _ => return None,
707        },
708        _ => return None,
709    })
710}
711
712/// The situation each inventory key is said in: the lines of campaign context a
713/// writer needs to write it from its intent rather than from its words — the
714/// quest it belongs to, the objective, what the beat does to the story (its
715/// `happening`), what the speaker is doing, the NPC line an option answers.
716///
717/// A procedural derivation from the stage documents, keyed exactly like
718/// [`inventory`]; a key with nothing to add maps to an empty list. Consumed by
719/// `delvec l10n-inventory`, which hands it to a transcreator beside the English
720/// (`tools/creator/i18n-translate.py`). Authoring context the player never sees
721/// (`happening.text`, a cast placement's `doing`) is exactly what this carries.
722pub fn key_situations(c: &Campaign) -> BTreeMap<String, Vec<String>> {
723    let goals: BTreeMap<&str, &str> = c
724        .quest_plan
725        .content
726        .quests
727        .iter()
728        .map(|q| (local(q.id.as_str()), q.goal.as_str()))
729        .collect();
730    let quests: BTreeMap<&str, &crate::Quest> = c
731        .quests
732        .content
733        .quests
734        .iter()
735        .map(|q| (local(q.id.as_str()), q))
736        .collect();
737    let npc_names: BTreeMap<&str, &str> = c
738        .npcs
739        .content
740        .npcs
741        .iter()
742        .map(|n| (local(n.id.as_str()), n.name.as_str()))
743        .collect();
744    let mut nodes: BTreeMap<(&str, &str), &crate::DialogueNode> = BTreeMap::new();
745    for tree in &c.dialogue.content.dialogues {
746        for node in &tree.nodes {
747            nodes.insert((local(tree.npc.as_str()), local(node.id.as_str())), node);
748        }
749    }
750    let shops: BTreeMap<&str, &crate::Shop> = c
751        .quests
752        .content
753        .shops
754        .iter()
755        .map(|s| (local(s.id.as_str()), s))
756        .collect();
757    let mut beats: BTreeMap<String, &str> = BTreeMap::new();
758    each_effect_ref(c, &mut |_stage, _path, keybase, eff| {
759        if let Some(h) = &eff.happening {
760            beats.insert(keybase.to_string(), h.text.as_str());
761        }
762    });
763
764    let objective = |q: &str, o: &str| {
765        quests
766            .get(q)
767            .and_then(|q| q.objectives.iter().find(|x| local(x.id().as_str()) == o))
768    };
769    let quest_lines = |q: &str, out: &mut Vec<String>, own_goal: bool| {
770        if !own_goal && let Some(g) = goals.get(q) {
771            out.push(format!("Quest: {g}"));
772        }
773        if let Some(h) = quests.get(q).and_then(|x| x.happening.as_ref()) {
774            out.push(format!("What the quest does to the story: {}", h.text));
775        }
776    };
777    let option_lines = |np: &str, nd: &str, oi: &str, out: &mut Vec<String>, own: &str| {
778        let Some(node) = nodes.get(&(np, nd)) else {
779            return;
780        };
781        out.push(format!("Answers the NPC line: {}", node.text));
782        let Some(opt) = oi.parse::<usize>().ok().and_then(|i| node.options.get(i)) else {
783            return;
784        };
785        if own != "label" {
786            out.push(format!("Caption on the button: {}", opt.label));
787        }
788        if own == "label"
789            && let Some(t) = &opt.tooltip
790        {
791            out.push(format!("Full line in the tooltip: {t}"));
792        }
793        if let Some(h) = &opt.happening {
794            out.push(format!("Choosing it: {}", h.text));
795        }
796    };
797
798    let mut out = BTreeMap::new();
799    for key in inventory(c).into_keys() {
800        let segs: Vec<&str> = key.split('.').collect();
801        let mut lines: Vec<String> = Vec::new();
802        match segs.as_slice() {
803            ["world", "boundary", "message"] => {
804                lines.push("Shown when a player walks past the edge of the map.".into())
805            }
806            ["world", "outro"] => {
807                lines.push("The closing line, shown when the delve is complete.".into())
808            }
809            ["class", cl, "blurb"] | ["class", cl, "kit", _, "name"] => {
810                if let Some(class) = c
811                    .classes
812                    .content
813                    .classes
814                    .iter()
815                    .find(|x| local(x.id.as_str()) == *cl)
816                {
817                    lines.push(format!("Class: {}", class.name));
818                }
819            }
820            ["quest", q, "goal"] => quest_lines(q, &mut lines, true),
821            ["obj", q, o, field] => {
822                quest_lines(q, &mut lines, false);
823                if let Some(obj) = objective(q, o) {
824                    if *field != "title"
825                        && let Some(t) = obj.title()
826                    {
827                        lines.push(format!("Objective: {t}"));
828                    }
829                    if *field != "hint"
830                        && let Some(h) = obj.hint()
831                    {
832                        lines.push(format!("Objective hint: {h}"));
833                    }
834                    if let Some(h) = obj.happening() {
835                        lines.push(format!("Completing it: {}", h.text));
836                    }
837                }
838            }
839            ["cast", q, np, b, "bark", _] => {
840                quest_lines(q, &mut lines, false);
841                let doing = quests
842                    .get(q)
843                    .and_then(|x| {
844                        x.cast
845                            .iter()
846                            .find(|(id, _)| local(id.as_str()) == *np)
847                            .map(|(_, e)| e)
848                    })
849                    .and_then(|e| {
850                        b.parse::<usize>()
851                            .ok()
852                            .and_then(|i| e.placements().get(i).copied())
853                    })
854                    .and_then(|p| p.doing.as_deref());
855                if let Some(d) = doing {
856                    let who = npc_names.get(np).copied().unwrap_or(np);
857                    lines.push(format!("{who}, during this quest: {d}"));
858                }
859            }
860            ["dlg", np, nd, "text"] => {
861                if let Some(node) = nodes.get(&(*np, *nd))
862                    && !node.options.is_empty()
863                {
864                    let labels: Vec<&str> = node.options.iter().map(|o| o.label.as_str()).collect();
865                    lines.push(format!("The player can answer: {}", labels.join(" / ")));
866                }
867            }
868            ["dlg", np, nd, "opt", oi, field] => option_lines(np, nd, oi, &mut lines, field),
869            ["lethal", _, "message"] => {
870                lines.push("Shown to a player as this hazard kills them.".into())
871            }
872            ["stake", _, "collected"] => {
873                lines.push("Shown when a player picks up what they dropped at death.".into())
874            }
875            ["shop", h, rest @ ..] => {
876                if let Some(shop) = shops.get(h) {
877                    if rest != ["title"] {
878                        lines.push(format!("Shop: {}", shop.title));
879                    }
880                    if let [_, i, "tooltip"] = rest
881                        && let Some(off) = i.parse::<usize>().ok().and_then(|i| shop.offers.get(i))
882                    {
883                        lines.push(format!("Caption on the button: {}", off.label));
884                    }
885                }
886            }
887            ["fx", rest @ ..] => {
888                match rest {
889                    ["trig", ..] => lines.push("Fires from a trigger placed in the world.".into()),
890                    ["trap", ..] => lines.push("Fires from a trap.".into()),
891                    ["sc", ..] => lines.push("Fires when a shortcut opens.".into()),
892                    ["death", ..] => lines.push("Fires when a player dies.".into()),
893                    ["dlg", np, nd, oi, ..] => {
894                        if let Some(opt) = nodes
895                            .get(&(*np, *nd))
896                            .and_then(|n| oi.parse::<usize>().ok().and_then(|i| n.options.get(i)))
897                        {
898                            lines.push(format!("Fires after the player chooses: {}", opt.label));
899                        }
900                    }
901                    ["shop", h, oi, ..] => {
902                        if let Some(shop) = shops.get(h)
903                            && let Some(off) =
904                                oi.parse::<usize>().ok().and_then(|i| shop.offers.get(i))
905                        {
906                            lines.push(format!(
907                                "Fires after buying: {} (shop: {})",
908                                off.label, shop.title
909                            ));
910                        }
911                    }
912                    [q, "oc", o, ..] => {
913                        quest_lines(q, &mut lines, false);
914                        if let Some(obj) = objective(q, o) {
915                            let name = obj.title().unwrap_or(o);
916                            lines.push(format!("Fires when this objective is done: {name}"));
917                            if let Some(h) = obj.happening() {
918                                lines.push(format!("Completing it: {}", h.text));
919                            }
920                        }
921                    }
922                    [q, "done", ..] => {
923                        quest_lines(q, &mut lines, false);
924                        lines.push("Fires when the quest completes.".into());
925                    }
926                    _ => {}
927                }
928                // The nearest enclosing effect that states a beat: the effect
929                // holding this string, else the sequence or bundle around it.
930                let mut base: &str = key.rsplit_once('.').map(|(b, _)| b).unwrap_or(&key);
931                loop {
932                    if let Some(text) = beats.get(base) {
933                        lines.push(format!("This beat: {text}"));
934                        break;
935                    }
936                    match base.rsplit_once('.') {
937                        Some((up, _)) => base = up,
938                        None => break,
939                    }
940                }
941            }
942            _ => {}
943        }
944        out.insert(key, lines);
945    }
946    out
947}
948
949/// One `narrate` `art` occurrence (DSL v0.6, spec-0014): its stage-doc path (for
950/// diagnostics), its l10n inventory key, and the canonical English text.
951#[derive(Clone, Debug, PartialEq, Eq)]
952pub struct ArtNarrate {
953    /// The stage document the string was authored in (`quests` / `dialogue`).
954    pub stage: &'static str,
955    /// JSON-pointer-ish path within that stage doc.
956    pub path: String,
957    /// The l10n inventory key (`fx.…​.narrate`) — always present, since every
958    /// `narrate` lives in an inventoried effect position.
959    pub key: String,
960    /// The canonical English text.
961    pub text: String,
962}
963
964/// Every `narrate` effect using the v0.6 `art` style, in a fixed deterministic
965/// order. Its l10n `key` is derived by the **same** traversal/keying as
966/// [`inventory`]/[`each_string`], so the compiler's art-font glyph check
967/// (`DW0328`) can look each art string up in every declared-language sidecar.
968/// Every art narrate lives in a quest `on_objective_complete`/`on_complete` or an
969/// environment trigger — all inventoried — so each `key` is guaranteed present in
970/// a fully-covered sidecar.
971pub fn art_narrates(c: &Campaign) -> Vec<ArtNarrate> {
972    let mut out = Vec::new();
973    each_effect_ref(c, &mut |stage, path, keybase, eff| {
974        if let Some(text) = eff.narrate_art_text() {
975            out.push(ArtNarrate {
976                stage,
977                path: format!("{path}/text"),
978                key: format!("{keybase}.narrate"),
979                text: text.to_string(),
980            });
981        }
982    });
983    out
984}
985
986/// One on-screen `narrate` occurrence — `title`, `subtitle` or `art` — its stage-doc
987/// path, its l10n inventory key, its style, and the canonical English text.
988#[derive(Clone, Debug, PartialEq, Eq)]
989pub struct ScreenNarrate {
990    /// The stage document the string was authored in (`quests` / `dialogue`).
991    pub stage: &'static str,
992    /// JSON-pointer-ish path within that stage doc.
993    pub path: String,
994    /// The l10n inventory key (`fx.…​.narrate`).
995    pub key: String,
996    /// Which on-screen channel vanilla draws it in — this selects the width budget.
997    pub style: NarrateStyle,
998    /// The canonical English text.
999    pub text: String,
1000}
1001
1002/// Every `narrate` effect vanilla draws **on screen** (`title` / `subtitle` / `art`),
1003/// in a fixed deterministic order. Like [`art_narrates`], each `key` is derived by the
1004/// **same** traversal/keying as [`inventory`]/[`each_string`], so the compiler's
1005/// text-fit check (`DW0330`) can look every string up in each declared-language
1006/// sidecar and report it under the offending locale and key. `chat` narrates are
1007/// excluded: chat wraps and scrolls, so it has no width budget.
1008pub fn on_screen_narrates(c: &Campaign) -> Vec<ScreenNarrate> {
1009    let mut out = Vec::new();
1010    each_effect_ref(c, &mut |stage, path, keybase, eff| {
1011        if let Some((style, text)) = eff.narrate_on_screen() {
1012            out.push(ScreenNarrate {
1013                stage,
1014                path: format!("{path}/text"),
1015                key: format!("{keybase}.narrate"),
1016                style,
1017                text: text.to_string(),
1018            });
1019        }
1020    });
1021    out
1022}
1023
1024/// One dialogue option label — the caption vanilla draws on a fixed-width dialog
1025/// button — with its stage-doc path, its l10n inventory key and the canonical
1026/// English text.
1027#[derive(Clone, Debug, PartialEq, Eq)]
1028pub struct OptionLabel {
1029    /// The stage document the string was authored in (`dialogue` for a real
1030    /// dialogue option; a bonfire's labels carry the stage they were authored in).
1031    pub stage: &'static str,
1032    /// JSON-pointer-ish path within that stage doc.
1033    pub path: String,
1034    /// The l10n inventory key (`dlg.<npc>.<node>.opt.<i>.label`).
1035    pub key: String,
1036    /// The canonical English text.
1037    pub text: String,
1038}
1039
1040/// Every dialogue option label, in a fixed deterministic order (declaration order:
1041/// tree, then node, then option). Each `key` is derived by the **same** keying as
1042/// [`inventory`]/[`each_string`], so the compiler's button-width check (`DW0331`)
1043/// can look every label up in each declared-language sidecar and report an
1044/// overflowing translation under its own locale and key.
1045///
1046/// Every option label is emitted as a button caption exactly once per node variant
1047/// (`emit::build_node_dialog`); display gating (`requires_flags`/`forbids_flags`)
1048/// only decides *whether* a variant shows it, never how wide it renders, so gated
1049/// and ungated options carry the same budget and are all visited here.
1050pub fn dialogue_option_labels(c: &Campaign) -> Vec<OptionLabel> {
1051    let mut out = Vec::new();
1052    for (ti, tree) in c.dialogue.content.dialogues.iter().enumerate() {
1053        let np = local(tree.npc.as_str());
1054        for (ni, node) in tree.nodes.iter().enumerate() {
1055            let nd = local(node.id.as_str());
1056            for (oi, opt) in node.options.iter().enumerate() {
1057                out.push(OptionLabel {
1058                    stage: "dialogue",
1059                    path: format!("/content/dialogues/{ti}/nodes/{ni}/options/{oi}/label"),
1060                    key: format!("dlg.{np}.{nd}.opt.{oi}.label"),
1061                    text: opt.label.clone(),
1062                });
1063            }
1064        }
1065    }
1066    out
1067}
1068
1069/// Every effect emission can lower, top-level and nested, as `(stage, JSON
1070/// pointer, l10n key base)` in the fixed inventory order: the pointer is what
1071/// the replay records as fired (`flow::JournalStep::fired`), and the key base is
1072/// what the effect's own strings are keyed under (`<keybase>.narrate`,
1073/// `<keybase>.give`, …). The pairing [`each_string`] keys by, exposed so a play
1074/// order can place an effect's strings at the step that fires it.
1075pub fn effect_string_sites(c: &Campaign) -> Vec<(&'static str, String, String)> {
1076    let mut out = Vec::new();
1077    each_effect_ref(c, &mut |stage, path, keybase, _eff| {
1078        out.push((stage, path.to_string(), keybase.to_string()));
1079    });
1080    out
1081}
1082
1083/// Every **authored** bonfire rest-dialog label (spec-0016 §1), in the same
1084/// fixed effect order the inventory uses. A bonfire's
1085/// two options are drawn on exactly the same 150-GUI-px `multi_action` button a
1086/// dialogue option is, so they carry exactly the same width budget (`DW0331`) —
1087/// the check follows the widget, not the stage the string was authored in.
1088///
1089/// Unauthored labels are absent by construction: the compiler's canonical English
1090/// (`Rest and save` / `Save only` / `Bonfire`) is measured once by a compiler unit
1091/// test rather than re-measured per campaign, since it cannot vary.
1092pub fn bonfire_option_labels(c: &Campaign) -> Vec<OptionLabel> {
1093    let mut out = Vec::new();
1094    each_effect_ref(c, &mut |stage, path, keybase, eff| {
1095        let Some(l) = eff.bonfire_labels() else {
1096            return;
1097        };
1098        for (text, field, key) in [
1099            (l.rest_label, "rest_label", "rest_label"),
1100            (l.save_label, "save_label", "save_label"),
1101        ] {
1102            if let Some(text) = text {
1103                out.push(OptionLabel {
1104                    stage,
1105                    path: format!("{path}/{field}"),
1106                    key: format!("{keybase}.{key}"),
1107                    text: text.to_string(),
1108                });
1109            }
1110        }
1111    });
1112    out
1113}
1114
1115/// The **five effect roots** the compiler can lower a quest effect from, as
1116/// `(stage, json_path, l10n keybase)` for each root's `i`-th top-level effect.
1117///
1118/// The roots themselves are **not enumerated here**. They come from
1119/// [`crate::effects::for_each_effect_root`], the one enumeration in the workspace,
1120/// which this walk simply indexes into per top-level effect (`path` + `/{i}`,
1121/// `key` + `.{i}`). Before that module existed this function held its own copy of
1122/// the root list and [`effect_roots_mut`] held a second one, which is the
1123/// arrangement that let a walk go blind to a root — twice, independently, in this
1124/// file alone. The paths and keys are unchanged in both directions.
1125fn effect_roots(c: &Campaign) -> Vec<EffectRoot<'_>> {
1126    let mut out = Vec::new();
1127    crate::effects::for_each_effect_root(c, &mut |site, list| {
1128        for (i, eff) in list.iter().enumerate() {
1129            out.push(EffectRoot {
1130                stage: site.stage,
1131                path: format!("{}/{i}", site.path),
1132                key: format!("{}.{i}", site.key),
1133                eff,
1134            });
1135        }
1136    });
1137    out
1138}
1139
1140/// One top-level effect root: where it lives, what its l10n keys hang off, and the
1141/// effect itself.
1142struct EffectRoot<'a> {
1143    /// The stage document (`quests` / `dialogue`) this effect was authored in.
1144    stage: &'static str,
1145    /// JSON pointer to the effect within that document.
1146    path: String,
1147    /// The effect's l10n key prefix.
1148    key: String,
1149    /// The effect.
1150    eff: &'a QuestEffect,
1151}
1152
1153/// The **mutable mirror** of [`effect_roots`]: the identical roots, in the
1154/// identical order, with the same `(stage, path, key)` descriptors, exposed
1155/// mutably so [`each_string`] (and through it [`localize`]) can rewrite the
1156/// player-visible strings in place.
1157///
1158/// Like [`effect_roots`] it enumerates nothing itself — it indexes
1159/// [`crate::effects::for_each_effect_root_mut`], which is generated from the
1160/// **same macro body** as the immutable walk. The two mirrors are therefore
1161/// lockstep by construction rather than by a test that has to be remembered; the
1162/// descriptor-equality test below now pins that property instead of establishing
1163/// it.
1164fn effect_roots_mut(c: &mut Campaign) -> Vec<(&'static str, String, String, &mut QuestEffect)> {
1165    let mut out: Vec<(&'static str, String, String, &mut QuestEffect)> = Vec::new();
1166    crate::effects::for_each_effect_root_mut(c, &mut |kind, path, key, list| {
1167        for (i, eff) in list.iter_mut().enumerate() {
1168            out.push((
1169                kind.stage(),
1170                format!("{path}/{i}"),
1171                format!("{key}.{i}"),
1172                eff,
1173            ));
1174        }
1175    });
1176    out
1177}
1178
1179/// Visit every effect emission can lower — **top-level and every
1180/// transitively-nested** one (a `sequence` step, an
1181/// `on_respawn`/`on_caught`/`on_arrive` bundle) — over all five
1182/// [`effect_roots`], in the fixed inventory order, invoking
1183/// `f(stage, path, keybase, effect)`. `stage` names the document the effect lives
1184/// in (`quests` or `dialogue`) and `path` is its JSON pointer within it, so a
1185/// diagnostic can point at the real site; `keybase` is its l10n key prefix, derived
1186/// by the **same** position-keying as [`each_string`]/[`effect_strings_deep`] (so an
1187/// art narrate's key matches its inventory key). Shared by [`art_narrates`],
1188/// [`on_screen_narrates`], [`bonfire_option_labels`], [`sound_refs`] and
1189/// [`play_sound_actor_refs`], so the consumer checks
1190/// (`DW0326`/`DW0328`/`DW0330`/`DW0331`/`DW0335`) see exactly the strings the
1191/// inventory demands a translation for.
1192fn each_effect_ref<'a>(
1193    c: &'a Campaign,
1194    f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
1195) {
1196    for r in effect_roots(c) {
1197        effect_deep(r.eff, r.stage, &r.path, &r.key, f);
1198    }
1199}
1200
1201/// Visit `eff` and every transitively-nested effect (depth-first, pre-order),
1202/// threading the JSON-pointer `path` and l10n `keybase` through each nested list via
1203/// [`QuestEffect::nested_effect_lists_labeled`] (path segment + key segment + the
1204/// per-effect index). The key segments match [`effect_strings_deep`] exactly.
1205fn effect_deep<'a>(
1206    eff: &'a QuestEffect,
1207    stage: &'static str,
1208    path: &str,
1209    keybase: &str,
1210    f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
1211) {
1212    f(stage, path, keybase, eff);
1213    for (pseg, kseg, list) in eff.nested_effect_lists_labeled() {
1214        for (j, inner) in list.iter().enumerate() {
1215            effect_deep(
1216                inner,
1217                stage,
1218                &format!("{path}/{pseg}/{j}"),
1219                &format!("{keybase}.{kseg}.{j}"),
1220                f,
1221            );
1222        }
1223    }
1224}
1225
1226/// One vanilla sound-event reference (DSL v0.6/v0.4): its stage-doc path and the
1227/// referenced id, for registry validation (`DW0326`).
1228#[derive(Clone, Debug, PartialEq, Eq)]
1229pub struct SoundRef {
1230    /// The stage document the reference was authored in (`quests` / `dialogue`).
1231    pub stage: &'static str,
1232    /// JSON-pointer-ish path within that stage doc.
1233    pub path: String,
1234    /// The referenced sound-event id (`minecraft:` prefix optional).
1235    pub sound: String,
1236}
1237
1238/// Every vanilla sound-event id referenced by a quest/trigger effect — a
1239/// `play-sound`'s `sound` (v0.6) and a `narrate`'s optional `sound` (v0.4) — in a
1240/// fixed deterministic order, for `DW0326` validation.
1241pub fn sound_refs(c: &Campaign) -> Vec<SoundRef> {
1242    let mut out = Vec::new();
1243    each_effect_ref(c, &mut |stage, path, _key, eff| {
1244        for (sub, sound) in eff.sound_refs() {
1245            out.push(SoundRef {
1246                stage,
1247                path: format!("{path}/{sub}"),
1248                sound: sound.to_string(),
1249            });
1250        }
1251    });
1252    out
1253}
1254
1255/// Every `play-sound` effect using the unsupported `at: actor` target, in a fixed
1256/// order, as `(path, actor-id)` pairs. The actor variant is accepted by the
1257/// schema and rejected (`DW0335`) by the compiler, which resolves no position for
1258/// a live actor. `SoundRef::sound` carries the actor id.
1259pub fn play_sound_actor_refs(c: &Campaign) -> Vec<SoundRef> {
1260    let mut out = Vec::new();
1261    each_effect_ref(c, &mut |stage, path, _key, eff| {
1262        if let Some(actor) = eff.play_sound_actor() {
1263            out.push(SoundRef {
1264                stage,
1265                path: format!("{path}/at/actor"),
1266                sound: actor.to_string(),
1267            });
1268        }
1269    });
1270    out
1271}
1272
1273/// Replace every inventoried string in `c` with its translation from
1274/// `translations` (an l10n sidecar's `content`). Keys absent from the map are left
1275/// as canonical English — but a fully-validated sidecar ([`validate_l10n`]) covers
1276/// the inventory exactly, so a build only reaches here with complete coverage.
1277pub fn localize(c: &mut Campaign, translations: &BTreeMap<String, String>) {
1278    each_string(c, &mut |key, value| {
1279        if let Some(t) = translations.get(key) {
1280            *value = t.clone();
1281        }
1282    });
1283}
1284
1285// ---------------------------------------------------------------------------
1286// i18n v2 — translation tags and the Minecraft language-code table (spec-0029)
1287// ---------------------------------------------------------------------------
1288
1289/// The reserved Unicode **private-use** character that delimits a *translation
1290/// tag* — the in-band form that carries an inventory key alongside its canonical
1291/// English from the stage docs to the text component the compiler emits it into.
1292///
1293/// A tagged string is `<SIGIL><key><SIGIL><english>` ([`tag`]). It exists only
1294/// between [`tag_translatables`] and emission: every emitter that lowers an
1295/// authored string into a **text component** splits it back apart and emits
1296/// `{"translate": key, "fallback": english}` (spec-0029 §1), and every consumer
1297/// that wants the human string calls [`plain`].
1298///
1299/// The point of an in-band tag is that a site which *fails* to do either leaks the
1300/// sigil into the built tree, where the compiler's own output scan sees it and
1301/// fails the build (`DW0185`). That turns "prove every authored string lands in a
1302/// component" from an audit that rots into an invariant the compiler re-proves on
1303/// every build, including for emitters not yet written.
1304///
1305/// U+E000 is the first code point of the Basic Multilingual Plane's Private Use
1306/// Area: it has no character assignment, so no authored or translated content can
1307/// legitimately contain it. [`validate_tr_sigil`] (`DW0183`) reserves the whole
1308/// block anyway, so the tag can never be forged or shadowed by content.
1309pub const TR_SIGIL: char = '\u{E000}';
1310
1311/// The reserved private-use range [`TR_SIGIL`] is drawn from (`U+E000..=U+F8FF`).
1312/// Reserved wholesale so a near-miss cannot be authored either.
1313const PUA: std::ops::RangeInclusive<char> = '\u{E000}'..='\u{F8FF}';
1314
1315/// Build the translation tag for `key` over its canonical English `english`.
1316pub fn tag(key: &str, english: &str) -> String {
1317    format!("{TR_SIGIL}{key}{TR_SIGIL}{english}")
1318}
1319
1320/// The root segment of the **pack key space** — the key space a delve writes into
1321/// its resource pack and references from its text components. Distinct from every
1322/// key space a delve authors in, and from the compiler's own
1323/// [`chrome::RESERVED_PREFIX`](crate::chrome::RESERVED_PREFIX), so a key's first
1324/// segment says which space it is in.
1325pub const PACK_KEY_ROOT: &str = "delve";
1326
1327/// The prefix every key of one delve's pack key space carries:
1328/// `delve.<campaign_id>.`.
1329///
1330/// # One delve, one vocabulary
1331///
1332/// A campaign's own key space (`world.title`, `npc.<n>.name`, …) is
1333/// **campaign-relative**: it identifies a row inside one campaign's documents, and
1334/// the l10n sidecar that answers it sits in that campaign's directory. Two
1335/// campaigns naming the same row therefore write the same key, which is correct
1336/// where those keys live and catastrophic where they end up.
1337///
1338/// Where they end up is a Minecraft client's **merged language table**, and that
1339/// table is not per-delve. It is the union of every applied resource pack:
1340/// `tools/creator/playtest-server.sh` installs each delve's pack into the player's
1341/// `resourcepacks/` directory as `<campaign_id>.zip`, where it stays enabled
1342/// across servers and worlds, and a server-pushed pack merges on top of whatever
1343/// is already applied. A `{"translate": …, "fallback": …}` component renders its
1344/// `fallback` **only when the key is absent from that merged table**, so any delve
1345/// whose pack is still applied answers for every other delve that asks the same
1346/// key — one delve's completion toast reading another delve's title, in a language
1347/// the delve it was playing does not ship.
1348///
1349/// So every key that leaves a delve — into a component, into a lang file — is
1350/// namespaced by the campaign that owns it, and two delves' vocabularies are
1351/// disjoint sets. The campaign id is the right grain: it is what the pack file is
1352/// named after, so a rebuilt campaign replaces its own pack rather than joining it.
1353///
1354/// Campaign ids are kebab tokens ([`CampaignId::is_valid_syntax`]) and carry no
1355/// `.`, so the namespace of one id can never be a prefix of another's key.
1356pub fn pack_namespace(campaign_id: &str) -> String {
1357    format!("{PACK_KEY_ROOT}.{campaign_id}.")
1358}
1359
1360/// One key of `campaign_id`'s [pack key space](pack_namespace): the key as the
1361/// campaign's own documents and sidecars know it, under that campaign's namespace.
1362/// The single authority — the tagger, the chrome resolver and the lang-file writer
1363/// all build their keys here, so what a component references and what the pack
1364/// defines cannot drift.
1365pub fn pack_key(campaign_id: &str, key: &str) -> String {
1366    format!("{}{key}", pack_namespace(campaign_id))
1367}
1368
1369/// The directory one delve's baked skin textures occupy inside the pack's shared
1370/// asset namespace: `<campaign_id>/`.
1371///
1372/// # The same defect, in the other space a pack writes into
1373///
1374/// A lang key and a texture id are the same kind of thing: a name a delve writes
1375/// into a space the CLIENT owns. Enabled resource packs merge per path and stay
1376/// enabled across servers and worlds, so `assets/delvewright/textures/npc/keeper.png`
1377/// is `keeper`'s face in every delve applying a pack — two delves that both cast a
1378/// `keeper` render each other's faces, exactly as two delves that both name
1379/// `world.title` read each other's titles. The grain is the campaign id for the
1380/// same reason [`pack_namespace`] gives: the pack file is named after it, so a
1381/// rebuilt campaign replaces its own textures rather than joining them.
1382///
1383/// # Why this is not [`pack_key`]'s dotted prefix
1384///
1385/// A texture id is not a key, it is a **resource-location path**, and a path's
1386/// namespace separator is `/`. Vanilla's own assets nest by directory
1387/// (`textures/entity/villager/…`); a `.` inside a final path segment is legal by
1388/// the grammar but has no vanilla precedent, and this engine does not invent
1389/// notation where established practice answers. Same grain, same reasoning, the
1390/// separator the space uses.
1391///
1392/// Campaign ids are kebab tokens ([`CampaignId::is_valid_syntax`]) and so carry no
1393/// `/`, so one delve's directory can never be a prefix of another's texture id.
1394pub fn pack_texture_dir(campaign_id: &str) -> String {
1395    format!("{campaign_id}/")
1396}
1397
1398/// One texture id of `campaign_id`'s [texture space](pack_texture_dir): the id as
1399/// the campaign's own documents know it (`skins/<texture_id>.png`, `DW0190`,
1400/// `DW0309`), under that campaign's directory. The single authority — the
1401/// mannequin's `profile.texture` and the pack's archive path are both built from
1402/// it, so what a summon points at and what the pack ships cannot drift.
1403pub fn pack_texture_id(campaign_id: &str, texture_id: &str) -> String {
1404    format!("{}{texture_id}", pack_texture_dir(campaign_id))
1405}
1406
1407/// Rewrite every skin declaration in `c` to carry its
1408/// [pack texture id](pack_texture_id), returning `pack id → authored id` — the
1409/// map a caller needs to find `skins/<authored id>.png` on disk.
1410///
1411/// **The funnel, and the reason this is a rewrite rather than a rule emitters
1412/// follow.** A body's texture reaches emission exactly one way: an emitter reads
1413/// `skin.texture_id` off the body it is summoning. Applying the namespace at those
1414/// emit sites is a rule each of them has to remember — the shape that let an
1415/// actor's skin be emitted but never baked. Applying it here, at the one walk over
1416/// every body that declares a skin ([`crate::body_skins_mut`], the mutable
1417/// mirror of [`crate::body_skin_sites`]), leaves no un-namespaced id in the
1418/// campaign for a new emit site to find: a summon written tomorrow is namespaced
1419/// because there is nothing else to read. Same shape as [`tag_translatables`],
1420/// which is why it sits beside it.
1421///
1422/// **The creator's key space does not move.** `texture_id` is what a creator
1423/// writes in `npcs.json`/`quests.json` and names `skins/<texture_id>.png` after,
1424/// and `DW0190` (malformed id) and `DW0309` (missing PNG) both read it
1425/// as authored — every one of them runs on the campaign *before* this rewrite, and
1426/// `validate`/`analyze`, which never emit, never reach it at all.
1427///
1428/// Two bodies may name one texture (a character and the puppet that plays it), so
1429/// the returned map is keyed by pack id and is smaller than the walk.
1430pub fn namespace_skin_textures(c: &mut Campaign) -> BTreeMap<String, String> {
1431    let dir = pack_texture_dir(c.world.campaign_id.as_str());
1432    let mut sources = BTreeMap::new();
1433    for skin in crate::body_skins_mut(c) {
1434        let packed = format!("{dir}{}", skin.texture_id);
1435        sources.insert(
1436            packed.clone(),
1437            std::mem::replace(&mut skin.texture_id, packed),
1438        );
1439    }
1440    sources
1441}
1442
1443/// Split a translation tag into `(key, english)`. `None` for an untagged string —
1444/// a compiler-baked literal such as the default boundary message, which has no
1445/// inventory key and is translated by neither v1 nor v2.
1446pub fn untag(s: &str) -> Option<(&str, &str)> {
1447    let rest = s.strip_prefix(TR_SIGIL)?;
1448    let (key, english) = rest.split_once(TR_SIGIL)?;
1449    Some((key, english))
1450}
1451
1452/// The human string behind `s`: its English source if `s` is a translation tag,
1453/// otherwise `s` unchanged. The accessor every **non-component** consumer of an
1454/// authored string uses — the build manifest, the reviewer chronicles, the bot's
1455/// `critical-path.json`, the generated PackTest sources. Each such site is a named
1456/// exclusion in `docs/reference/compiler.md`: it is not a text component, so it
1457/// cannot carry a translate key, and it is not read by a player.
1458///
1459/// It is the **visible** text: a styled span (spec-0096) reads as its own text,
1460/// with the markup dropped ([`crate::textstyle::visible`]), because none of these
1461/// readers draws a style.
1462pub fn plain(s: &str) -> std::borrow::Cow<'_, str> {
1463    crate::textstyle::visible(untag(s).map(|(_, e)| e).unwrap_or(s))
1464}
1465
1466/// Whether `s` contains any reserved private-use character — i.e. whether it is,
1467/// or embeds, a translation tag. The predicate the compiler's build-output scan
1468/// (`DW0185`) runs over every emitted byte.
1469pub fn has_tr_sigil(s: &str) -> bool {
1470    s.chars().any(|c| PUA.contains(&c))
1471}
1472
1473/// Rewrite every inventoried player-visible string in `c` into its translation tag
1474/// ([`tag`]), returning the canonical-English inventory it was derived from.
1475///
1476/// Runs on the exact same traversal as [`inventory`] and [`localize`]
1477/// ([`each_string`]), so the tagged set and the translated set are the same set by
1478/// construction — the property spec-0029 keeps.
1479///
1480/// The campaign handed to the compiler is tagged **once**, before the plan is
1481/// built; from there the tag is the compiler's only evidence that a string it is
1482/// about to emit is player-visible and translatable.
1483///
1484/// **The tag carries the [pack key](pack_key), not the inventory key.** The
1485/// returned inventory is the campaign's own key space, unchanged — that is what a
1486/// sidecar answers and what `DW0180` counts — while what travels to emission, and
1487/// so into every component and lang file, is `delve.<campaign_id>.<key>`. The two
1488/// differ exactly where they are read: a sidecar is read inside one campaign's
1489/// directory, a lang file inside a client's shared language table.
1490pub fn tag_translatables(c: &mut Campaign) -> BTreeMap<String, String> {
1491    let ns = pack_namespace(c.world.campaign_id.as_str());
1492    let mut inv = BTreeMap::new();
1493    each_string(c, &mut |key, value| {
1494        inv.insert(key.to_string(), value.clone());
1495        *value = tag(&format!("{ns}{key}"), value);
1496    });
1497    inv
1498}
1499
1500/// Reserve the private-use block the translation tag is built from (`DW0183`): no
1501/// player-visible string — authored English (the whole [`inventory`]) or any
1502/// declared language's sidecar rendition — may contain a `U+E000..=U+F8FF`
1503/// character. Language-independent; runs beside [`validate_marker_channel`] on
1504/// every `validate` / `analyze` / `build`.
1505pub fn validate_tr_sigil(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
1506    let mut d = Vec::new();
1507    let mut flag = |where_: String, key: &str, text: &str| {
1508        let Some(bad) = text.chars().find(|ch| PUA.contains(ch)) else {
1509            return;
1510        };
1511        d.push(Diagnostic::error(
1512            codes::TR_SIGIL_RESERVED,
1513            "l10n",
1514            where_,
1515            format!(
1516                "player-visible string `{key}` contains the reserved private-use character \
1517                 U+{:04X} — that block is how the compiler carries an l10n key into the text \
1518                 component this string is emitted as, and it has no rendering in any \
1519                 Minecraft font. Remove U+{:04X} from the line",
1520                bad as u32, bad as u32
1521            ),
1522        ));
1523    };
1524    for (key, text) in inventory(c) {
1525        flag(format!("#/{key}"), &key, &text);
1526    }
1527    for (lang, doc) in sidecars {
1528        for (key, text) in &doc.content {
1529            flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
1530        }
1531    }
1532    d
1533}
1534
1535/// Every declared-language code this build knows how to write a lang file for, in
1536/// declaration order, as `(declared code, minecraft code)`. `Err` names the first
1537/// unmapped code (`DW0184`) — a language is never silently dropped.
1538pub fn declared_mc_codes(c: &Campaign) -> Result<Vec<(String, &'static str)>, Diagnostic> {
1539    let mut out = Vec::new();
1540    for lang in &c.world.content.languages {
1541        match crate::mclang::mc_lang_code(lang) {
1542            Some(mc) => out.push((lang.clone(), mc)),
1543            None => {
1544                return Err(Diagnostic::error(
1545                    codes::LANG_CODE_UNMAPPED,
1546                    "world",
1547                    format!("/content/languages/{lang}"),
1548                    format!(
1549                        "declared language `{lang}` has no Minecraft language-file code — the \
1550                         resource pack has nowhere to write its \
1551                         `assets/delvewright/lang/<code>.json`, and the language would ship \
1552                         invisible. Use a code the pinned 1.21.11 client really loads \
1553                         (`dsl::mclang::CLIENT_LANGS`, derived from Mojang's own asset index) \
1554                         — e.g. `zh-cn`, `ja-jp`, `de-de`"
1555                    ),
1556                ));
1557            }
1558        }
1559    }
1560    Ok(out)
1561}
1562
1563/// **Translation provenance** (`DW0187` / `DW0188`): is each sidecar row still a
1564/// translation of the English it renders?
1565///
1566/// [`validate_l10n`] proves the sidecar's key SET equals the inventory's. That is
1567/// silent about whether a row still *corresponds* to its key: rewrite an authored
1568/// line and its translation is present, applied and wrong, with no key moved. The
1569/// sidecar's [`L10nDoc::source`] map closes it by recording the English each row
1570/// was translated from, so the compiler can compare instead of a human auditing.
1571///
1572/// Two findings:
1573///
1574/// * `DW0187` — a recorded source differs from the key's canonical English (the
1575///   row is stale), or names a key the sidecar does not translate at all (the
1576///   provenance itself is stale).
1577/// * `DW0188` — rows with no recorded provenance, **counted**. Those rows are
1578///   unguarded, and saying so on every run is what keeps an unadopted sidecar
1579///   from reading like a checked one. Warning tier: `source` is additive, and
1580///   this is the one-version deprecation window before it is required.
1581///
1582/// The entity display-name rule makes this more than hygiene. A name key belongs
1583/// to the first site declaring a given text, so renaming ONE body can migrate a
1584/// key to ANOTHER — the row that goes stale is not the row the author edited, and
1585/// the missing-key half of the move (`DW0180`) points somewhere else entirely.
1586pub fn validate_l10n_provenance(
1587    c: &Campaign,
1588    sidecars: &BTreeMap<String, L10nDoc>,
1589) -> Vec<Diagnostic> {
1590    let mut d = Vec::new();
1591    if c.world.content.languages.is_empty() {
1592        return d;
1593    }
1594    let inv = inventory(c);
1595    for lang in &c.world.content.languages {
1596        let Some(doc) = sidecars.get(lang) else {
1597            continue; // absent sidecar is DW0180's finding, not this one's.
1598        };
1599        for (key, was) in &doc.source {
1600            match inv.get(key) {
1601                Some(now) if now == was => {}
1602                Some(now) => d.push(Diagnostic::error(
1603                    codes::L10N_STALE,
1604                    "l10n",
1605                    format!("l10n/{lang}.json#/source/{key}"),
1606                    format!(
1607                        "`{key}` was translated from {was:?} but now reads {now:?} — the \
1608                         translation in `content` still renders the old line and would ship \
1609                         attached to the new one. Re-translate `{key}` and update its `source` \
1610                         (`tools/creator/i18n-translate.py <campaign> --lang {lang}` does both). If a \
1611                         RENAME surprised you here: an entity display name's key belongs to the \
1612                         first body declaring that text, so renaming one body can hand its key \
1613                         to another"
1614                    ),
1615                )),
1616                None => d.push(Diagnostic::error(
1617                    codes::L10N_STALE,
1618                    "l10n",
1619                    format!("l10n/{lang}.json#/source/{key}"),
1620                    format!(
1621                        "`source` records `{key}`, which is not in the string inventory — the \
1622                         provenance is stale even if the translation is gone. Remove `{key}` \
1623                         from `source` in `l10n/{lang}.json`"
1624                    ),
1625                )),
1626            }
1627        }
1628        // Every row `source` does not cover is a row DW0187 cannot see. Report the
1629        // count: an unadopted sidecar must never look like a checked one.
1630        let unguarded = doc
1631            .content
1632            .keys()
1633            .filter(|k| !doc.source.contains_key(*k))
1634            .count();
1635        if unguarded > 0 {
1636            let total = doc.content.len();
1637            d.push(Diagnostic::warning(
1638                codes::L10N_PROVENANCE_MISSING,
1639                "l10n",
1640                format!("l10n/{lang}.json"),
1641                format!(
1642                    "{unguarded} of {total} translated rows record no `source`, so nothing can \
1643                     tell whether they still translate the English they render — an edited line \
1644                     leaves its translation present, applied and wrong, and no key moves. Run \
1645                     `tools/creator/i18n-translate.py <campaign> --lang {lang}` to record provenance for \
1646                     the rows it already has. This warning is the one-version deprecation \
1647                     window; `source` becomes required after it"
1648                ),
1649            ));
1650        }
1651    }
1652    d
1653}
1654
1655/// Coverage + envelope validation for every declared language's l10n sidecar
1656/// (`DW0180` / `DW0181`). Language-independent: it runs on every `validate` /
1657/// `analyze` / `build`, regardless of `--lang`. Returns no diagnostics for a
1658/// campaign that declares no languages. `sidecars` is keyed by language code
1659/// (the `l10n/<code>.json` filename stem).
1660pub fn validate_l10n(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
1661    let mut d = Vec::new();
1662    let declared = &c.world.content.languages;
1663    if declared.is_empty() {
1664        return d;
1665    }
1666    // A sidecar must carry every key of the inventory (`DW0180`) and no key
1667    // outside it (`DW0181`).
1668    let inv = inventory(c);
1669    let required_keys: BTreeSet<&str> = inv.keys().map(String::as_str).collect();
1670    let inv_keys: BTreeSet<&str> = required_keys.clone();
1671    let campaign_id = c.world.campaign_id.as_str();
1672
1673    for lang in declared {
1674        if lang == CANONICAL_LANG {
1675            d.push(Diagnostic::error(
1676                codes::L10N_MISSING,
1677                "l10n",
1678                format!("/world/languages/{lang}"),
1679                format!(
1680                    "`{lang}` is the canonical language and must not be declared in \
1681                     `world.languages` — English is implicit; remove `{lang}` from the list"
1682                ),
1683            ));
1684            continue;
1685        }
1686        let Some(doc) = sidecars.get(lang) else {
1687            d.push(Diagnostic::error(
1688                codes::L10N_MISSING,
1689                "l10n",
1690                format!("l10n/{lang}.json"),
1691                format!(
1692                    "declared language `{lang}` has no `l10n/{lang}.json` sidecar — add the \
1693                     sidecar (a full key→translation map), or remove `{lang}` from \
1694                     `world.languages`"
1695                ),
1696            ));
1697            continue;
1698        };
1699        // Envelope consistency (folded into DW0180 — the sidecar does not correctly
1700        // cover the declared language).
1701        if doc.campaign_id.as_str() != campaign_id {
1702            d.push(Diagnostic::error(
1703                codes::L10N_MISSING,
1704                "l10n",
1705                format!("l10n/{lang}.json"),
1706                format!(
1707                    "sidecar `campaign_id` `{}` differs from the campaign's `{campaign_id}` — set \
1708                     the sidecar's `campaign_id` to `{campaign_id}`",
1709                    doc.campaign_id
1710                ),
1711            ));
1712        }
1713        if doc.lang != *lang {
1714            d.push(Diagnostic::error(
1715                codes::L10N_MISSING,
1716                "l10n",
1717                format!("l10n/{lang}.json"),
1718                format!(
1719                    "sidecar `lang` field `{}` differs from filename code `{lang}` — set the \
1720                     sidecar's `lang` to `{lang}` (it must match the `l10n/{lang}.json` filename)",
1721                    doc.lang
1722                ),
1723            ));
1724        }
1725        if doc.dsl_version != DSL_VERSION {
1726            d.push(Diagnostic::error(
1727                codes::L10N_MISSING,
1728                "l10n",
1729                format!("l10n/{lang}.json"),
1730                format!(
1731                    "sidecar declares dsl_version `{}` — set it to `{DSL_VERSION}`, the one \
1732                     number this engine accepts, matching the stage documents",
1733                    doc.dsl_version,
1734                ),
1735            ));
1736        }
1737        // Coverage: missing (DW0180) against what this campaign's version owes,
1738        // orphan (DW0181) against the whole inventory.
1739        let side_keys: BTreeSet<&str> = doc.content.keys().map(String::as_str).collect();
1740        for missing in required_keys.difference(&side_keys) {
1741            d.push(Diagnostic::error(
1742                codes::L10N_MISSING,
1743                "l10n",
1744                format!("l10n/{lang}.json"),
1745                format!(
1746                    "sidecar is missing a translation for inventory key `{missing}` — add \
1747                     `{missing}` to `l10n/{lang}.json` (coverage must be exact)"
1748                ),
1749            ));
1750        }
1751        for orphan in side_keys.difference(&inv_keys) {
1752            d.push(Diagnostic::error(
1753                codes::L10N_ORPHAN,
1754                "l10n",
1755                format!("l10n/{lang}.json#/content/{orphan}"),
1756                format!(
1757                    "orphan key `{orphan}` is not in the string inventory — remove it from \
1758                     `l10n/{lang}.json` (the sidecar must cover exactly the inventory, no extras)"
1759                ),
1760            ));
1761        }
1762    }
1763    d
1764}