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