Skip to main content

delvewright_dsl/
chrome.rs

1//! Compiler **chrome**: the player-visible strings the compiler writes itself,
2//! which no campaign authored and no translator was ever asked for
3//! (spec-0029 addendum).
4//!
5//! A delve's on-screen text has two authors. The campaign writes its own lines,
6//! and those travel through the l10n inventory ([`crate::l10n`]) into a sidecar a
7//! translator fills in. The **compiler** writes the rest — `New objective: `,
8//! `Delve Complete`, `Choose your class`, and the default a `bonfire` shows when
9//! the campaign authors no label — and without a module like this one they have
10//! no key, no sidecar entry and no way to be anything but English, so a player
11//! reading a fully translated delve still sees English chrome wrapped around it.
12//!
13//! ## Why chrome is compiler-owned rather than authored
14//!
15//! The obvious fix — inventory them like any other string — charges every campaign
16//! for text it did not write: a translator answering the same thirteen keys,
17//! identically, once per delve, forever. And it would be answering them for
18//! **product chrome**: `New objective: `, `Delve Complete`, `Choose your class`
19//! are not lines any campaign author wants to write. That eight of them never grew
20//! an authored override was a correct judgment, not an oversight, which is exactly
21//! why the answer is not to add one — it would move the engine's maintenance cost
22//! onto content.
23//!
24//! The other five are **diegetic**: in-world text a campaign legitimately wants in
25//! its own voice, and each already has its authored override (`boundary.message`,
26//! `close-gate.sealed_hint`, a bonfire's `prompt`/`rest_label`/`save_label`). Those
27//! overrides are unchanged and still win; what lives here is only the **default**
28//! the compiler bakes when the campaign authors nothing, which used to be English
29//! in every language.
30//!
31//! ## Key space
32//!
33//! Chrome keys are `delvewright.ui.<area>.<name>`. The prefix makes collision
34//! impossible in both directions, by construction rather than by policing:
35//!
36//! * a **campaign** key is always one of the fixed kinds the l10n key scheme
37//!   derives (`world.` / `area.` / `class.` / `npc.` / `quest.` / `obj.` / `dlg.` /
38//!   `wave.` / `actor.` / `loot.` / `cast.` / `fx.`), so no campaign string can
39//!   produce a `delvewright.` key — and a sidecar that writes one anyway is
40//!   `DW0186` ([`validate_chrome_namespace`]), the guard that sits beside the other
41//!   reserved-channel guards;
42//! * **vanilla** owns every other namespace in a language file and will never
43//!   define `delvewright.*`, so our entries cannot shadow a Minecraft string.
44//!
45//! ## Sentences, not fragments
46//!
47//! Four chrome strings frame a value — an objective title, the delve's name, the
48//! live party count. They are **one key with `%s` placeholders**, never a key
49//! concatenated with a component, because a concatenation freezes English word
50//! order into every language: `"%s — complete."` lets a translator put the title
51//! where the sentence needs it. Vanilla's `translate` component takes `with`
52//! arguments for exactly this, so the placeholder is the intended primitive rather
53//! than a workaround (CLAUDE.md no-hack doctrine). A [`ChromeString`] declares how
54//! many arguments it takes, and a language table whose placeholder count disagrees
55//! with the English fails a unit test rather than a player's screen.
56//!
57//! ## Delivery, and the honest fallback
58//!
59//! Chrome rides the same road the campaign's strings do (spec-0029): each chrome
60//! string enters emission as a translation tag ([`ChromeString::tagged`]), an
61//! emitter lowers it into `{"translate": key, "fallback": <text>, "with": […]}`,
62//! and `delvec build` writes the chrome entries into the resource pack's language
63//! files beside the campaign's.
64//!
65//! Chrome is **carried for every language it has a table for and emitted only into
66//! the language files the delve already writes** — `en_us` plus the campaign's
67//! declared languages. Emitting every table would give, say, a French client on a
68//! Chinese-only campaign French chrome around English story: partial in a way that
69//! reads as broken, where uniform English does not. The tables still pay off with
70//! no engine change: they activate the moment a campaign declares that language.
71//!
72//! For a language with no table the key is simply **absent** from that file: the
73//! client resolves it through `en_us.json` (or, for a player who declined the pack,
74//! through the component's own `fallback`) and reads English. Absent, never faked —
75//! writing English into `fr_fr.json` would render the same glyphs while claiming to
76//! be a translation.
77//!
78//! ## Translation provenance
79//!
80//! English is canonical (CLAUDE.md language policy); every table below is derived
81//! from it. **These are unreviewed translations** — machine-produced, not
82//! checked by a native speaker — which is defensible for thirteen short functional
83//! strings and is recorded here so nobody mistakes them for reviewed work. A
84//! correction is a one-line edit to a table.
85
86use std::collections::BTreeMap;
87
88use crate::diagnostic::{Diagnostic, codes};
89use crate::l10n::{L10nDoc, tag};
90
91/// One compiler-authored player-visible string.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct ChromeString {
94    /// The stable `delvewright.ui.…` translation key.
95    pub key: &'static str,
96    /// The canonical English text, `%s` for each argument.
97    pub en: &'static str,
98    /// How many `with` arguments the component supplies.
99    pub args: usize,
100    /// This string's index in [`ALL`] — the slot a language table fills.
101    pub slot: usize,
102}
103
104impl ChromeString {
105    /// This string as a **translation tag** over `text` — the in-band form
106    /// spec-0029 carries a key on, so an emitter lowers chrome through exactly the
107    /// helpers an authored string uses, and a site that fails to lower it trips
108    /// `DW0185` exactly as it would for authored text.
109    fn tagged_as(&self, text: &str) -> String {
110        tag(self.key, text)
111    }
112
113    /// This string as a translation tag over its canonical English, under the
114    /// **bare** chrome key.
115    ///
116    /// The plan-time form, for a default the compiler bakes before it knows the
117    /// build's language or the delve's key namespace. It is not emittable as it
118    /// stands: [`Chrome::rebind`] is what turns it into the delve's own pack key,
119    /// and `crates/delvec/tests/i18n_v2.rs` fails the build of any tree that emits
120    /// a bare key, so a site that forgets to rebind is a red rather than a string
121    /// another delve's pack can answer.
122    pub fn tagged(&self) -> String {
123        self.tagged_as(self.en)
124    }
125}
126
127/// Declare a chrome string and its slot in [`ALL`] in one place, so the two can
128/// never disagree.
129macro_rules! chrome {
130    ($( $(#[$m:meta])* $name:ident = $slot:literal, $key:literal, $args:literal, $en:expr; )*) => {
131        $(
132            $(#[$m])*
133            pub const $name: ChromeString = ChromeString {
134                key: $key, en: $en, args: $args, slot: $slot,
135            };
136        )*
137        /// Every compiler-authored player-visible string, in slot order. The single
138        /// authority: the language tables are indexed by it, the lang-file writer
139        /// walks it, and the coverage report counts it.
140        pub const ALL: &[ChromeString] = &[$($name),*];
141    };
142}
143
144chrome! {
145    /// An objective's activation announcement. `%s` is its title.
146    OBJECTIVE_NEW = 0, "delvewright.ui.objective.new", 1, "New objective: %s";
147    /// An objective's completion confirmation. `%s` is its title.
148    OBJECTIVE_COMPLETE = 1, "delvewright.ui.objective.complete", 1, "Objective complete: %s";
149    /// The campaign-completion chat line. `%s` is the delve's title.
150    CAMPAIGN_COMPLETE = 2, "delvewright.ui.campaign.complete", 1, "%s — complete.";
151    /// The signature under the campaign-completion line.
152    CAMPAIGN_SIGNATURE = 3, "delvewright.ui.campaign.signature", 0, "A Delvewright delve.";
153    /// The finale title banner.
154    CAMPAIGN_BANNER = 4, "delvewright.ui.campaign.banner", 0, "Delve Complete";
155    /// The lobby actionbar (`world.min_players`). `%s` is the live party count,
156    /// then the size the delve requires.
157    LOBBY_WAITING = 5, "delvewright.ui.lobby.waiting", 2, "Waiting for the party — %s / %s";
158    /// The class-selection dialog's title.
159    CLASS_TITLE = 6, "delvewright.ui.class.title", 0, "Choose your class";
160    /// The class-selection dialog's body line.
161    CLASS_BODY = 7, "delvewright.ui.class.body", 0, "Pick the kit you will carry.";
162    /// The playable-region return message when the campaign authors no
163    /// `world.boundary.message` (spec-0013).
164    BOUNDARY_MESSAGE = 8, "delvewright.ui.boundary.message", 0,
165        "The tide turns you back — the delve lies behind you.";
166    /// What a sealed gate answers a right-click with when the `close-gate` authors
167    /// no `sealed_hint` (DSL v0.8).
168    GATE_SEALED = 9, "delvewright.ui.gate.sealed", 0, "The way is sealed.";
169    /// The bonfire rest dialog's title when the effect authors no `prompt`.
170    BONFIRE_TITLE = 10, "delvewright.ui.bonfire.title", 0, crate::stages::BONFIRE_PROMPT_EN;
171    /// The bonfire's **rest and save** button when no `rest_label` is authored.
172    BONFIRE_REST = 11, "delvewright.ui.bonfire.rest", 0, crate::stages::BONFIRE_REST_LABEL_EN;
173    /// The bonfire's **save only** button when no `save_label` is authored.
174    BONFIRE_SAVE = 12, "delvewright.ui.bonfire.save", 0, crate::stages::BONFIRE_SAVE_LABEL_EN;
175}
176
177/// One language's rendition of [`ALL`], in slot order.
178type Table = [&'static str; 13];
179
180// --- the tables ------------------------------------------------------------
181// Slot order is ALL's: objective.new, objective.complete, campaign.complete,
182// campaign.signature, campaign.banner, lobby.waiting, class.title, class.body,
183// boundary.message, gate.sealed, bonfire.title, bonfire.rest, bonfire.save.
184
185const ZH_HANS: Table = [
186    "新目标:%s",
187    "目标完成:%s",
188    "%s——完成。",
189    "一场 Delvewright 秘境。",
190    "秘境通关",
191    "等待队伍集结 —— %s / %s",
192    "选择你的职业",
193    "挑选你要携带的装备。",
194    "潮水将你送回——秘境在你身后。",
195    "此路已封。",
196    "篝火",
197    "休息并存档",
198    "仅存档",
199];
200
201const ZH_HANT: Table = [
202    "新目標:%s",
203    "目標完成:%s",
204    "%s——完成。",
205    "一場 Delvewright 秘境。",
206    "秘境通關",
207    "等待隊伍集結 —— %s / %s",
208    "選擇你的職業",
209    "挑選你要攜帶的裝備。",
210    "潮水將你送回——秘境在你身後。",
211    "此路已封。",
212    "篝火",
213    "休息並存檔",
214    "僅存檔",
215];
216
217const JA: Table = [
218    "新しい目標: %s",
219    "目標達成: %s",
220    "%s — クリア。",
221    "Delvewright の探索です。",
222    "探索完了",
223    "パーティを待っています — %s / %s",
224    "クラスを選択",
225    "携行する装備を選ぼう。",
226    "潮に押し戻された — 探索地は後ろだ。",
227    "道は閉ざされている。",
228    "焚き火",
229    "休息してセーブ",
230    "セーブのみ",
231];
232
233const KO: Table = [
234    "새 목표: %s",
235    "목표 완료: %s",
236    "%s — 완료.",
237    "Delvewright 탐험.",
238    "탐험 완료",
239    "일행을 기다리는 중 — %s / %s",
240    "직업을 선택하세요",
241    "가져갈 장비를 고르세요.",
242    "조류가 당신을 되돌려 보낸다 — 탐험지는 뒤에 있다.",
243    "길이 막혀 있다.",
244    "모닥불",
245    "휴식하고 저장",
246    "저장만",
247];
248
249const DE: Table = [
250    "Neues Ziel: %s",
251    "Ziel erfüllt: %s",
252    "%s – abgeschlossen.",
253    "Ein Delvewright-Abenteuer.",
254    "Abenteuer abgeschlossen",
255    "Warten auf die Gruppe – %s / %s",
256    "Wähle deine Klasse",
257    "Wähle die Ausrüstung, die du mitnimmst.",
258    "Die Flut trägt dich zurück – das Abenteuer liegt hinter dir.",
259    "Der Weg ist versperrt.",
260    "Lagerfeuer",
261    "Rasten und speichern",
262    "Nur speichern",
263];
264
265const FR: Table = [
266    "Nouvel objectif : %s",
267    "Objectif accompli : %s",
268    "%s — terminé.",
269    "Une aventure Delvewright.",
270    "Aventure terminée",
271    "En attente du groupe — %s / %s",
272    "Choisissez votre classe",
273    "Choisissez l'équipement que vous emporterez.",
274    "La marée vous repousse — l'aventure est derrière vous.",
275    "Le passage est scellé.",
276    "Feu de camp",
277    "Se reposer et sauvegarder",
278    "Sauvegarder seulement",
279];
280
281const ES: Table = [
282    "Nuevo objetivo: %s",
283    "Objetivo completado: %s",
284    "%s: completada.",
285    "Una aventura de Delvewright.",
286    "Aventura completada",
287    "Esperando al grupo — %s / %s",
288    "Elige tu clase",
289    "Elige el equipo que llevarás.",
290    "La marea te hace retroceder: la aventura queda a tus espaldas.",
291    "El paso está sellado.",
292    "Hoguera",
293    "Descansar y guardar",
294    "Solo guardar",
295];
296
297const PT_BR: Table = [
298    "Novo objetivo: %s",
299    "Objetivo concluído: %s",
300    "%s — concluída.",
301    "Uma aventura Delvewright.",
302    "Aventura concluída",
303    "Aguardando o grupo — %s / %s",
304    "Escolha sua classe",
305    "Escolha o equipamento que vai levar.",
306    "A maré te empurra de volta — a aventura ficou para trás.",
307    "A passagem está selada.",
308    "Fogueira",
309    "Descansar e salvar",
310    "Apenas salvar",
311];
312
313const PT_PT: Table = [
314    "Novo objetivo: %s",
315    "Objetivo concluído: %s",
316    "%s — concluída.",
317    "Uma aventura Delvewright.",
318    "Aventura concluída",
319    "À espera do grupo — %s / %s",
320    "Escolhe a tua classe",
321    "Escolhe o equipamento que vais levar.",
322    "A maré empurra-te de volta — a aventura ficou para trás.",
323    "A passagem está selada.",
324    "Fogueira",
325    "Descansar e guardar",
326    "Apenas guardar",
327];
328
329const RU: Table = [
330    "Новая цель: %s",
331    "Цель выполнена: %s",
332    "%s — пройдено.",
333    "Приключение Delvewright.",
334    "Приключение пройдено",
335    "Ожидание отряда — %s / %s",
336    "Выберите класс",
337    "Выберите снаряжение, которое возьмёте с собой.",
338    "Прилив относит вас назад — приключение позади.",
339    "Путь запечатан.",
340    "Костёр",
341    "Отдохнуть и сохранить",
342    "Только сохранить",
343];
344
345const IT: Table = [
346    "Nuovo obiettivo: %s",
347    "Obiettivo completato: %s",
348    "%s — completata.",
349    "Un'avventura Delvewright.",
350    "Avventura completata",
351    "In attesa del gruppo — %s / %s",
352    "Scegli la tua classe",
353    "Scegli l'equipaggiamento che porterai.",
354    "La marea ti respinge — l'avventura è alle tue spalle.",
355    "Il passaggio è sigillato.",
356    "Falò",
357    "Riposa e salva",
358    "Salva soltanto",
359];
360
361const NL: Table = [
362    "Nieuw doel: %s",
363    "Doel voltooid: %s",
364    "%s — voltooid.",
365    "Een Delvewright-avontuur.",
366    "Avontuur voltooid",
367    "Wachten op het gezelschap — %s / %s",
368    "Kies je klasse",
369    "Kies de uitrusting die je meeneemt.",
370    "Het tij drijft je terug — het avontuur ligt achter je.",
371    "De doorgang is verzegeld.",
372    "Kampvuur",
373    "Rusten en opslaan",
374    "Alleen opslaan",
375];
376
377const PL: Table = [
378    "Nowy cel: %s",
379    "Cel ukończony: %s",
380    "%s — ukończona.",
381    "Przygoda Delvewright.",
382    "Przygoda ukończona",
383    "Oczekiwanie na drużynę — %s / %s",
384    "Wybierz swoją klasę",
385    "Wybierz ekwipunek, który zabierzesz.",
386    "Przypływ zawraca cię — przygoda została za tobą.",
387    "Przejście jest zapieczętowane.",
388    "Ognisko",
389    "Odpocznij i zapisz",
390    "Tylko zapisz",
391];
392
393const TR: Table = [
394    "Yeni hedef: %s",
395    "Hedef tamamlandı: %s",
396    "%s — tamamlandı.",
397    "Bir Delvewright macerası.",
398    "Macera tamamlandı",
399    "Takım bekleniyor — %s / %s",
400    "Sınıfını seç",
401    "Yanına alacağın teçhizatı seç.",
402    "Gelgit seni geri getiriyor — macera arkanda kaldı.",
403    "Yol mühürlendi.",
404    "Kamp ateşi",
405    "Dinlen ve kaydet",
406    "Sadece kaydet",
407];
408
409const UK: Table = [
410    "Нова ціль: %s",
411    "Ціль виконано: %s",
412    "%s — пройдено.",
413    "Пригода Delvewright.",
414    "Пригоду пройдено",
415    "Очікування загону — %s / %s",
416    "Оберіть клас",
417    "Оберіть спорядження, яке візьмете з собою.",
418    "Приплив відносить вас назад — пригода позаду.",
419    "Шлях запечатано.",
420    "Багаття",
421    "Відпочити та зберегти",
422    "Лише зберегти",
423];
424
425const CS: Table = [
426    "Nový cíl: %s",
427    "Cíl splněn: %s",
428    "%s — dokončeno.",
429    "Dobrodružství Delvewright.",
430    "Dobrodružství dokončeno",
431    "Čekání na družinu — %s / %s",
432    "Vyber si třídu",
433    "Vyber si výbavu, kterou si vezmeš.",
434    "Příliv tě vrací zpět — dobrodružství leží za tebou.",
435    "Cesta je zapečetěná.",
436    "Táborák",
437    "Odpočinout a uložit",
438    "Jen uložit",
439];
440
441const SK: Table = [
442    "Nový cieľ: %s",
443    "Cieľ splnený: %s",
444    "%s — dokončené.",
445    "Dobrodružstvo Delvewright.",
446    "Dobrodružstvo dokončené",
447    "Čaká sa na družinu — %s / %s",
448    "Vyber si triedu",
449    "Vyber si výstroj, ktorú si vezmeš.",
450    "Príliv ťa vracia späť — dobrodružstvo je za tebou.",
451    "Cesta je zapečatená.",
452    "Táborák",
453    "Oddýchnuť si a uložiť",
454    "Len uložiť",
455];
456
457const SV: Table = [
458    "Nytt mål: %s",
459    "Mål avklarat: %s",
460    "%s — avklarat.",
461    "Ett Delvewright-äventyr.",
462    "Äventyret avklarat",
463    "Väntar på sällskapet — %s / %s",
464    "Välj din klass",
465    "Välj utrustningen du tar med dig.",
466    "Tidvattnet för dig tillbaka — äventyret ligger bakom dig.",
467    "Vägen är förseglad.",
468    "Lägereld",
469    "Vila och spara",
470    "Spara endast",
471];
472
473const DA: Table = [
474    "Nyt mål: %s",
475    "Mål fuldført: %s",
476    "%s — fuldført.",
477    "Et Delvewright-eventyr.",
478    "Eventyret fuldført",
479    "Venter på selskabet — %s / %s",
480    "Vælg din klasse",
481    "Vælg det udstyr, du tager med.",
482    "Tidevandet fører dig tilbage — eventyret ligger bag dig.",
483    "Vejen er forseglet.",
484    "Bål",
485    "Hvil og gem",
486    "Gem kun",
487];
488
489const NB: Table = [
490    "Nytt mål: %s",
491    "Mål fullført: %s",
492    "%s — fullført.",
493    "Et Delvewright-eventyr.",
494    "Eventyret fullført",
495    "Venter på følget — %s / %s",
496    "Velg klassen din",
497    "Velg utstyret du tar med deg.",
498    "Tidevannet fører deg tilbake — eventyret ligger bak deg.",
499    "Veien er forseglet.",
500    "Bål",
501    "Hvil og lagre",
502    "Bare lagre",
503];
504
505const FI: Table = [
506    "Uusi tavoite: %s",
507    "Tavoite suoritettu: %s",
508    "%s — suoritettu.",
509    "Delvewright-seikkailu.",
510    "Seikkailu suoritettu",
511    "Odotetaan ryhmää — %s / %s",
512    "Valitse luokkasi",
513    "Valitse mukaan otettavat varusteet.",
514    "Vuorovesi kääntää sinut takaisin — seikkailu jää taaksesi.",
515    "Tie on sinetöity.",
516    "Nuotio",
517    "Lepää ja tallenna",
518    "Vain tallennus",
519];
520
521const HU: Table = [
522    "Új cél: %s",
523    "Cél teljesítve: %s",
524    "%s — teljesítve.",
525    "Egy Delvewright-kaland.",
526    "A kaland teljesítve",
527    "Várakozás a csapatra — %s / %s",
528    "Válaszd ki a kasztodat",
529    "Válaszd ki a magaddal vitt felszerelést.",
530    "Az ár visszasodor — a kaland mögötted maradt.",
531    "Az út le van pecsételve.",
532    "Tábortűz",
533    "Pihenés és mentés",
534    "Csak mentés",
535];
536
537const RO: Table = [
538    "Obiectiv nou: %s",
539    "Obiectiv îndeplinit: %s",
540    "%s — încheiată.",
541    "O aventură Delvewright.",
542    "Aventură încheiată",
543    "Se așteaptă grupul — %s / %s",
544    "Alege-ți clasa",
545    "Alege echipamentul pe care îl vei lua.",
546    "Mareea te împinge înapoi — aventura a rămas în urmă.",
547    "Trecerea este pecetluită.",
548    "Foc de tabără",
549    "Odihnește-te și salvează",
550    "Doar salvează",
551];
552
553const EL: Table = [
554    "Νέος στόχος: %s",
555    "Ο στόχος ολοκληρώθηκε: %s",
556    "%s — ολοκληρώθηκε.",
557    "Μια περιπέτεια Delvewright.",
558    "Η περιπέτεια ολοκληρώθηκε",
559    "Αναμονή για την ομάδα — %s / %s",
560    "Διάλεξε την κλάση σου",
561    "Διάλεξε τον εξοπλισμό που θα πάρεις μαζί σου.",
562    "Η παλίρροια σε γυρίζει πίσω — η περιπέτεια είναι πίσω σου.",
563    "Το πέρασμα είναι σφραγισμένο.",
564    "Φωτιά",
565    "Ξεκουράσου και αποθήκευσε",
566    "Μόνο αποθήκευση",
567];
568
569const BG: Table = [
570    "Нова цел: %s",
571    "Целта е изпълнена: %s",
572    "%s — завършено.",
573    "Приключение на Delvewright.",
574    "Приключението е завършено",
575    "Изчакване на групата — %s / %s",
576    "Избери своя клас",
577    "Избери снаряжението, което ще носиш.",
578    "Приливът те връща назад — приключението остана зад теб.",
579    "Пътят е запечатан.",
580    "Огън",
581    "Почини си и запази",
582    "Само запази",
583];
584
585const TH: Table = [
586    "เป้าหมายใหม่: %s",
587    "ทำเป้าหมายสำเร็จ: %s",
588    "%s — จบแล้ว",
589    "การผจญภัยของ Delvewright",
590    "ผจญภัยสำเร็จ",
591    "กำลังรอกลุ่ม — %s / %s",
592    "เลือกคลาสของคุณ",
593    "เลือกอุปกรณ์ที่คุณจะนำติดตัวไป",
594    "กระแสน้ำพัดคุณกลับมา — การผจญภัยอยู่ข้างหลังคุณแล้ว",
595    "ทางนี้ถูกปิดผนึกไว้",
596    "กองไฟ",
597    "พักและบันทึก",
598    "บันทึกเท่านั้น",
599];
600
601const VI: Table = [
602    "Mục tiêu mới: %s",
603    "Hoàn thành mục tiêu: %s",
604    "%s — hoàn thành.",
605    "Một cuộc phiêu lưu Delvewright.",
606    "Hoàn thành cuộc phiêu lưu",
607    "Đang chờ cả nhóm — %s / %s",
608    "Chọn lớp nhân vật của bạn",
609    "Chọn trang bị bạn sẽ mang theo.",
610    "Thủy triều đẩy bạn trở lại — cuộc phiêu lưu ở phía sau bạn.",
611    "Lối đi đã bị niêm phong.",
612    "Lửa trại",
613    "Nghỉ ngơi và lưu",
614    "Chỉ lưu",
615];
616
617const ID: Table = [
618    "Tujuan baru: %s",
619    "Tujuan selesai: %s",
620    "%s — selesai.",
621    "Sebuah petualangan Delvewright.",
622    "Petualangan selesai",
623    "Menunggu rombongan — %s / %s",
624    "Pilih kelasmu",
625    "Pilih perlengkapan yang akan kamu bawa.",
626    "Air pasang membawamu kembali — petualangan ada di belakangmu.",
627    "Jalan ini tersegel.",
628    "Api unggun",
629    "Istirahat dan simpan",
630    "Simpan saja",
631];
632
633const MS: Table = [
634    "Objektif baharu: %s",
635    "Objektif selesai: %s",
636    "%s — selesai.",
637    "Sebuah pengembaraan Delvewright.",
638    "Pengembaraan selesai",
639    "Menunggu kumpulan — %s / %s",
640    "Pilih kelas anda",
641    "Pilih kelengkapan yang akan anda bawa.",
642    "Air pasang membawa anda kembali — pengembaraan berada di belakang anda.",
643    "Laluan ini telah dimeterai.",
644    "Unggun api",
645    "Berehat dan simpan",
646    "Simpan sahaja",
647];
648
649const AR: Table = [
650    "هدف جديد: %s",
651    "اكتمل الهدف: %s",
652    "%s — اكتملت.",
653    "مغامرة من Delvewright.",
654    "اكتملت المغامرة",
655    "في انتظار الفريق — %s / %s",
656    "اختر فئتك",
657    "اختر العتاد الذي ستحمله.",
658    "المد يعيدك — المغامرة خلفك.",
659    "الطريق مغلق.",
660    "نار المخيم",
661    "استرح واحفظ",
662    "احفظ فقط",
663];
664
665const HE: Table = [
666    "יעד חדש: %s",
667    "היעד הושלם: %s",
668    "%s — הושלמה.",
669    "הרפתקה של Delvewright.",
670    "ההרפתקה הושלמה",
671    "ממתינים לחבורה — %s / %s",
672    "בחר את המחלקה שלך",
673    "בחר את הציוד שתישא איתך.",
674    "הגאות מחזירה אותך — ההרפתקה מאחוריך.",
675    "הדרך חתומה.",
676    "מדורה",
677    "לנוח ולשמור",
678    "לשמור בלבד",
679];
680
681const HI: Table = [
682    "नया लक्ष्य: %s",
683    "लक्ष्य पूरा हुआ: %s",
684    "%s — पूर्ण।",
685    "एक Delvewright साहसिक यात्रा।",
686    "साहसिक यात्रा पूर्ण",
687    "दल की प्रतीक्षा — %s / %s",
688    "अपनी श्रेणी चुनें",
689    "वह सामान चुनें जो आप साथ ले जाएँगे।",
690    "ज्वार आपको वापस मोड़ देता है — यात्रा आपके पीछे रह गई।",
691    "यह रास्ता बंद है।",
692    "अलाव",
693    "विश्राम करें और सहेजें",
694    "केवल सहेजें",
695];
696
697const CA: Table = [
698    "Nou objectiu: %s",
699    "Objectiu completat: %s",
700    "%s — completada.",
701    "Una aventura de Delvewright.",
702    "Aventura completada",
703    "Esperant el grup — %s / %s",
704    "Tria la teva classe",
705    "Tria l'equipament que t'enduràs.",
706    "La marea et fa retrocedir: l'aventura queda enrere.",
707    "El pas està segellat.",
708    "Foguera",
709    "Descansa i desa",
710    "Només desa",
711];
712
713const FIL: Table = [
714    "Bagong layunin: %s",
715    "Natapos ang layunin: %s",
716    "%s — tapos na.",
717    "Isang pakikipagsapalaran ng Delvewright.",
718    "Tapos na ang pakikipagsapalaran",
719    "Naghihintay sa grupo — %s / %s",
720    "Piliin ang iyong klase",
721    "Piliin ang kagamitang dadalhin mo.",
722    "Ibinalik ka ng agos — nasa likuran mo na ang pakikipagsapalaran.",
723    "Nakasara ang daan.",
724    "Siga",
725    "Magpahinga at mag-save",
726    "Mag-save lamang",
727];
728
729/// Which Minecraft language file gets which table. Keyed on the client's own file
730/// stem ([`crate::mclang::CLIENT_LANGS`]), so a locale variant is an explicit row
731/// rather than a prefix guess — `pt_br` and `pt_pt` genuinely differ, and `es_mx`
732/// genuinely does not.
733///
734/// A stem absent here has **no** chrome table and renders English (the honest
735/// fallback). English locales (`en_us`, `en_gb`, `en_au`, `en_ca`, `en_nz`) are
736/// absent for the same reason and read the canonical text directly.
737const TABLES: &[(&str, &Table)] = &[
738    ("ar_sa", &AR),
739    ("bg_bg", &BG),
740    ("ca_es", &CA),
741    ("cs_cz", &CS),
742    ("da_dk", &DA),
743    ("de_at", &DE),
744    ("de_ch", &DE),
745    ("de_de", &DE),
746    ("el_gr", &EL),
747    ("es_ar", &ES),
748    ("es_cl", &ES),
749    ("es_ec", &ES),
750    ("es_es", &ES),
751    ("es_mx", &ES),
752    ("es_uy", &ES),
753    ("es_ve", &ES),
754    ("fi_fi", &FI),
755    ("fil_ph", &FIL),
756    ("fr_ca", &FR),
757    ("fr_ch", &FR),
758    ("fr_fr", &FR),
759    ("he_il", &HE),
760    ("hi_in", &HI),
761    ("hu_hu", &HU),
762    ("id_id", &ID),
763    ("it_it", &IT),
764    ("ja_jp", &JA),
765    ("ko_kr", &KO),
766    ("ms_my", &MS),
767    ("nl_be", &NL),
768    ("nl_nl", &NL),
769    ("no_no", &NB),
770    ("pl_pl", &PL),
771    ("pt_br", &PT_BR),
772    ("pt_pt", &PT_PT),
773    ("ro_ro", &RO),
774    ("ru_ru", &RU),
775    ("sk_sk", &SK),
776    ("sv_se", &SV),
777    ("th_th", &TH),
778    ("tl_ph", &FIL),
779    ("tr_tr", &TR),
780    ("uk_ua", &UK),
781    ("vi_vn", &VI),
782    ("zh_cn", &ZH_HANS),
783    ("zh_hk", &ZH_HANT),
784    ("zh_tw", &ZH_HANT),
785];
786
787/// The chrome table for a Minecraft language stem, or `None` when the compiler
788/// ships none (in which case that language's chrome renders English).
789fn table(mc_code: &str) -> Option<&'static Table> {
790    TABLES.iter().find(|(c, _)| *c == mc_code).map(|(_, t)| *t)
791}
792
793/// The chrome entries to write into a language's
794/// `assets/delvewright/lang/<mc>.json`. Empty for a language with no table, so the
795/// client falls through to `en_us.json` (or to the component's own `fallback` if
796/// the player declined the pack) and reads English.
797pub fn lang_entries(mc_code: &str) -> BTreeMap<String, String> {
798    let Some(t) = table(mc_code) else {
799        return BTreeMap::new();
800    };
801    ALL.iter()
802        .map(|c| (c.key.to_string(), t[c.slot].to_string()))
803        .collect()
804}
805
806/// The canonical-English chrome entries — the `en_us.json` half, always complete.
807pub fn english_entries() -> BTreeMap<String, String> {
808    ALL.iter()
809        .map(|c| (c.key.to_string(), c.en.to_string()))
810        .collect()
811}
812
813/// How many of [`ALL`] a language really carries, as `(translated, total)`. Stated
814/// as a count because a table that binds to nothing must be a number, not an
815/// inference from silence.
816pub fn coverage(mc_code: &str) -> (usize, usize) {
817    (lang_entries(mc_code).len(), ALL.len())
818}
819
820/// `DW0186`: a campaign l10n sidecar may not define a key in the reserved chrome
821/// namespace. Sits beside the other reserved-channel guards
822/// ([`crate::l10n::validate_marker_channel`], [`crate::l10n::validate_tr_sigil`]):
823/// chrome is compiler-owned end to end, so a sidecar row under `delvewright.` is
824/// either a translator's mistake or an attempt to override text the engine owns —
825/// and if it were written into a lang file it would silently replace product
826/// chrome for that language. `DW0181` would also flag it as an orphan; this names
827/// the actual reason.
828pub fn validate_chrome_namespace(sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
829    let mut d = Vec::new();
830    for (lang, doc) in sidecars {
831        for key in doc.content.keys() {
832            if !key.starts_with(RESERVED_PREFIX) {
833                continue;
834            }
835            d.push(Diagnostic::error(
836                codes::CHROME_RESERVED,
837                "l10n",
838                format!("l10n/{lang}.json#/content/{key}"),
839                format!(
840                    "`{key}` is in the reserved `{RESERVED_PREFIX}` namespace, which the compiler \
841                     owns: those are the engine's own on-screen strings (`New objective: `, \
842                     `Choose your class`, …), they ship translated with the compiler, and no \
843                     campaign authors or overrides them. Remove `{key}` from \
844                     `l10n/{lang}.json` — to change what a campaign's own text says, translate \
845                     that campaign's key instead"
846                ),
847            ));
848        }
849    }
850    d
851}
852
853/// The reserved key prefix. No campaign l10n key can begin with it (the key scheme
854/// derives a fixed set of kinds) and no vanilla key does either.
855pub const RESERVED_PREFIX: &str = "delvewright.";
856
857/// How chrome is rendered for one build: which language's text rides on each
858/// component as its `fallback`.
859///
860/// * The default multi-language build (`delvec build`, spec-0029 v2) uses the
861///   canonical English, and the resource pack's language files carry the rest —
862///   the client picks. This is the release path.
863/// * A `--lang <code>` **bake** ships no language files at all (spec-0029 §4), so
864///   the fallback IS what the player reads: the compiler's translation for that
865///   language when it has one, its English otherwise. The `translate` key still
866///   rides along, harmlessly, and keeps `%s` substitution working — vanilla formats
867///   the fallback with the same `with` arguments.
868///
869/// It also carries **which delve's key space** those components reference. Chrome
870/// is engine-owned, but a lang file is not: the client merges every applied pack
871/// into one language table, so a globally-keyed chrome row from a delve that
872/// declares Chinese answers for a delve that ships English only, and the player
873/// reads Chinese chrome wrapped around an English story. Chrome keys are therefore
874/// namespaced exactly like campaign keys ([`crate::l10n::pack_key`]) — one delve,
875/// one vocabulary, chrome included — and `delvewright.` stays the segment that
876/// separates the compiler's strings from the campaign's *within* that namespace.
877#[derive(Clone, Debug, Default)]
878pub struct Chrome {
879    /// The bake's Minecraft language stem, or `None` for the multi-language build.
880    baked: Option<&'static Table>,
881    /// The delve's [pack namespace](crate::l10n::pack_namespace), prefixed onto
882    /// every key this resolver hands an emitter. Empty only for [`Self::default`],
883    /// which no emitting consumer may use (see the type's doc).
884    ns: String,
885}
886
887impl Chrome {
888    /// Resolve chrome for a build of `campaign_id`. `language` is `None` for the
889    /// default multi-language build and `Some(declared code)` for a `--lang` bake.
890    pub fn for_build(campaign_id: &str, language: Option<&str>) -> Self {
891        Self {
892            baked: language
893                .and_then(crate::mclang::mc_lang_code)
894                .and_then(table),
895            ns: crate::l10n::pack_namespace(campaign_id),
896        }
897    }
898
899    /// The text this build puts on the component.
900    fn text(&self, c: ChromeString) -> &str {
901        match self.baked {
902            Some(t) => t[c.slot],
903            None => c.en,
904        }
905    }
906
907    /// One chrome string, as the tagged form an emitter lowers into a component:
908    /// this delve's pack key over this build's rendition.
909    pub fn get(&self, c: ChromeString) -> String {
910        tag(&format!("{}{}", self.ns, c.key), self.text(c))
911    }
912
913    /// Re-resolve a string that may already carry a chrome tag, for the chrome
914    /// defaults the compiler bakes into its **plan** before the build language —
915    /// or the delve's namespace — is known (a `close-gate`'s `sealed_hint`, a
916    /// bonfire's three dialog strings). A plan-time default carries the bare
917    /// chrome key, which is what this looks up; an authored string — which carries
918    /// a campaign pack key — passes through untouched, as does any untagged
919    /// literal, and so does a chrome tag already bound (its key is namespaced, so
920    /// it matches no bare chrome key and rebinding is idempotent).
921    pub fn rebind(&self, s: &str) -> String {
922        let Some((key, _)) = crate::l10n::untag(s) else {
923            return s.to_string();
924        };
925        match ALL.iter().find(|c| c.key == key) {
926            Some(c) => self.get(*c),
927            None => s.to_string(),
928        }
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935
936    fn placeholders(s: &str) -> usize {
937        s.matches("%s").count()
938    }
939
940    /// Every chrome key is unique, reserved-prefixed (so it can collide with
941    /// neither a campaign l10n key nor a vanilla one), slot-consistent, and has
942    /// English whose `%s` count matches its declared arity.
943    #[test]
944    fn chrome_strings_are_well_formed() {
945        let mut seen = std::collections::BTreeSet::new();
946        for (i, c) in ALL.iter().enumerate() {
947            assert_eq!(c.slot, i, "`{}` declares the wrong slot", c.key);
948            assert!(
949                c.key.starts_with("delvewright.ui."),
950                "chrome key `{}` is outside the reserved namespace",
951                c.key
952            );
953            assert!(
954                c.key.starts_with(RESERVED_PREFIX),
955                "`{}` must live under the reserved prefix",
956                c.key
957            );
958            assert!(!c.en.is_empty(), "chrome key `{}` has no English", c.key);
959            assert_eq!(
960                placeholders(c.en),
961                c.args,
962                "`{}` declares {} args but its English has {}",
963                c.key,
964                c.args,
965                placeholders(c.en)
966            );
967            assert!(seen.insert(c.key), "duplicate chrome key `{}`", c.key);
968        }
969        assert_eq!(ALL.len(), 13, "the chrome inventory changed size");
970    }
971
972    /// **The binding that makes `%s` safe.** Every language table renders every
973    /// slot, non-empty, with exactly the placeholder count the English declares —
974    /// a translation that drops `%s` loses the objective's title on screen, and one
975    /// that adds a `%s` renders a substitution vanilla has no argument for.
976    #[test]
977    fn every_table_is_complete_and_placeholder_faithful() {
978        assert!(!TABLES.is_empty(), "no chrome table binds");
979        for (code, t) in TABLES {
980            assert!(
981                crate::mclang::CLIENT_LANGS.contains(code),
982                "`{code}` has a chrome table but is not a language the pinned client loads"
983            );
984            for c in ALL {
985                let s = t[c.slot];
986                assert!(!s.is_empty(), "`{code}` leaves `{}` empty", c.key);
987                assert_eq!(
988                    placeholders(s),
989                    c.args,
990                    "`{code}` renders `{}` with {} placeholder(s), English has {}",
991                    c.key,
992                    placeholders(s),
993                    c.args
994                );
995            }
996        }
997    }
998
999    /// The table list is sorted and has no duplicate stem: a second row for one
1000    /// language would be a silent, order-dependent override.
1001    #[test]
1002    fn table_rows_are_sorted_and_unique() {
1003        let codes: Vec<&str> = TABLES.iter().map(|(c, _)| *c).collect();
1004        assert!(
1005            codes.windows(2).all(|w| w[0] < w[1]),
1006            "TABLES must stay sorted by language stem"
1007        );
1008    }
1009
1010    /// `zh-cn` is fully covered: the owner reads Chinese, and Chinese chrome around
1011    /// Chinese story is the exact defect this module exists to close.
1012    #[test]
1013    fn zh_cn_is_complete() {
1014        assert_eq!(coverage("zh_cn"), (13, 13));
1015        assert_eq!(lang_entries("zh_cn")[CLASS_TITLE.key], "选择你的职业");
1016    }
1017
1018    /// A language with no table ships no chrome rows — absent, never English text
1019    /// masquerading as a translation.
1020    #[test]
1021    fn untabled_language_ships_no_chrome() {
1022        assert_eq!(coverage("tlh_aa"), (0, 13));
1023        assert!(lang_entries("en_us").is_empty());
1024        assert_eq!(english_entries().len(), 13);
1025    }
1026
1027    /// The multi-language build puts English on the component (the pack carries the
1028    /// rest); a bake puts the baked language on it, falling back to English. Every
1029    /// key a resolver hands out is the delve's, never the bare chrome key.
1030    #[test]
1031    fn build_carries_english_and_bake_carries_its_language() {
1032        let key = crate::l10n::pack_key("doune-castle", CLASS_TITLE.key);
1033
1034        let multi = Chrome::for_build("doune-castle", None);
1035        assert_eq!(multi.get(CLASS_TITLE), tag(&key, CLASS_TITLE.en));
1036        assert_eq!(
1037            multi.rebind(&CLASS_TITLE.tagged()),
1038            tag(&key, CLASS_TITLE.en)
1039        );
1040
1041        let zh = Chrome::for_build("doune-castle", Some("zh-cn"));
1042        assert_eq!(zh.get(CLASS_TITLE), tag(&key, "选择你的职业"));
1043        assert_eq!(zh.rebind(&CLASS_TITLE.tagged()), tag(&key, "选择你的职业"));
1044
1045        let klingon = Chrome::for_build("doune-castle", Some("tlh-aa"));
1046        assert_eq!(klingon.get(CLASS_TITLE), tag(&key, CLASS_TITLE.en));
1047    }
1048
1049    /// **One delve, one vocabulary, chrome included.** Two delves resolve the same
1050    /// chrome string to two keys, so a lang file one of them left applied in the
1051    /// client answers for nothing the other asks — the engine's own strings are in
1052    /// the pack too, and a globally-keyed one renders a delve's chrome in a
1053    /// language that delve does not ship.
1054    #[test]
1055    fn two_delves_share_no_chrome_key() {
1056        let a = Chrome::for_build("doune-castle", None);
1057        let b = Chrome::for_build("nobodys-cave-island", Some("zh-cn"));
1058        let mut n = 0;
1059        for c in ALL {
1060            let (ta, tb) = (a.get(*c), b.get(*c));
1061            let (ka, _) = crate::l10n::untag(&ta).expect("chrome is tagged");
1062            let (kb, _) = crate::l10n::untag(&tb).expect("chrome is tagged");
1063            assert_ne!(ka, kb, "`{}` is one key for two delves", c.key);
1064            n += 1;
1065        }
1066        assert_eq!(n, ALL.len(), "every chrome string compared");
1067    }
1068
1069    /// `rebind` only ever resolves chrome keys: an authored string that reaches it
1070    /// (a bonfire label the campaign really wrote) passes through unchanged, and so
1071    /// does a chrome tag already bound to a delve — rebinding is idempotent.
1072    #[test]
1073    fn rebind_leaves_authored_strings_alone() {
1074        let zh = Chrome::for_build("demo", Some("zh-cn"));
1075        assert_eq!(zh.rebind("Shrine fire"), "Shrine fire");
1076        let authored = tag(
1077            &crate::l10n::pack_key("demo", "fx.q.done.0.rest_prompt"),
1078            "Shrine fire",
1079        );
1080        assert_eq!(zh.rebind(&authored), authored);
1081        let bound = zh.get(CLASS_TITLE);
1082        assert_eq!(zh.rebind(&bound), bound);
1083    }
1084
1085    /// `DW0186`: a sidecar cannot define a chrome key.
1086    #[test]
1087    fn sidecar_may_not_shadow_chrome() {
1088        let mut content = BTreeMap::new();
1089        content.insert(CLASS_TITLE.key.to_string(), "俺の職業".to_string());
1090        content.insert("npc.keeper.name".to_string(), "門番".to_string());
1091        let doc = L10nDoc {
1092            dsl_version: crate::DSL_VERSION.to_string(),
1093            campaign_id: crate::ids::CampaignId("demo".to_string()),
1094            kind: crate::l10n::L10nKind::L10n,
1095            lang: "ja-jp".to_string(),
1096            content,
1097            source: BTreeMap::new(),
1098        };
1099        let d = validate_chrome_namespace(&BTreeMap::from([("ja-jp".to_string(), doc)]));
1100        assert_eq!(d.len(), 1, "exactly the chrome row is flagged");
1101        assert_eq!(d[0].code, codes::CHROME_RESERVED);
1102        assert!(d[0].message.contains(CLASS_TITLE.key));
1103    }
1104}