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