delvewright-dsl 0.36.1

Staged JSON DSL types and schemas for Delvewright adventure-map campaigns — the format the delvec compiler reads.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
//! Native i18n: author-declared languages, l10n sidecar documents, the
//! authoritative key inventory, and the localization pass (spec-0001 i18n
//! addendum).
//!
//! **English is canonical.** Stage docs stay pure English; the strings the
//! compiler emits to players come from the stage docs. A campaign that declares
//! `world.languages = ["<code>", …]` must ship one `l10n/<code>.json` sidecar per
//! declared language, each a flat map of **stable key → translated string**.
//!
//! The **key inventory** ([`inventory`]) is derived deterministically from the
//! stage docs; it is the single source of truth for both coverage validation
//! ([`validate_l10n`]) and the build-time swap ([`localize`]). Both walk the exact
//! same traversal ([`each_string`]), so a key can never be checked but not applied
//! (or vice-versa).
//!
//! ## Key scheme (stable, path-derived, collision-free)
//!
//! Keys are dotted paths built from the local part of each DSL id (the segment
//! after `<prefix>/`, kebab preserved). Ids are unique within their namespace, so
//! every key is unique.
//!
//! These are the keys **within one campaign** — what a sidecar answers, what
//! `DW0180` counts, what `delvec l10n-inventory` hands a translator. What leaves
//! the delve is each of them under that delve's own [pack namespace]
//! (`delve.<campaign_id>.`, [`pack_key`]), because a client merges every applied
//! resource pack into ONE language table and a campaign-relative key in there is
//! a key some other delve answers. See [`pack_namespace`].
//!
//! A delve's pack writes into a second client-global space with the same property
//! — the texture space its baked skins land in — so that namespace lives here too
//! ([`pack_texture_id`], [`namespace_skin_textures`]), beside the one it argues
//! from. What leaves a delve is namespaced in one place or in none.
//!
//! [pack namespace]: pack_namespace
//!
//! | Key | Source string |
//! |-----|---------------|
//! | `world.title` | stage-1 `content.title` |
//! | `area.<area>.name` | each stage-1 area `name` |
//! | `class.<class>.name` / `.blurb` | each stage-3 class |
//! | `class.<class>.kit.<i>.name` | a kit item's display `name` (only if set) |
//! | `npc.<npc>.name` | each stage-2 NPC `name` (see *entity display names* below) |
//! | `actor.<actor>.name` | each stage-5 actor `name` (v0.6, only if set; see below) |
//! | `quest.<quest>.goal` | each stage-4 planned-quest `goal` |
//! | `obj.<quest>.<obj>.title` / `.hint` | a stage-5 objective's `title`/`hint` (only if set) |
//! | `obj.<quest>.<obj>.missing_item_hint` | a stage-5 `interact`'s `missing_item_hint` (v0.7, only if set) |
//! | `obj.<quest>.<obj>.item_name` | a stage-5 `collect`'s `item_name` (v0.8, only if set) |
//! | `dlg.<npc>.<node>.text` | each stage-6 dialogue node `text` |
//! | `dlg.<npc>.<node>.opt.<i>.label` | each dialogue option `label` |
//! | `dlg.<npc>.<node>.opt.<i>.tooltip` | that option's hover `tooltip` (v0.8, only if set) |
//! | `wave.<wave>.mob.<i>.name` | a wave mob's custom `name` (only if set) |
//! | `wave.<wave>.mob.<i>.drop.<n>.name` | a declared quest-item drop's display `name` (v0.9, only if set) |
//! | `actor.<actor>.drop.<n>.name` | an actor's declared quest-item drop `name` (v0.9, only if set) |
//! | `fx.…​.narrate` / `fx.…​.give` | a `narrate` line / named `give-item` in an effect list |
//! | `fx.…​.rest_prompt` / `.rest_label` / `.save_label` | a `bonfire`'s authored rest-dialog strings (v0.8, only if set) |
//! | `fx.…​.rest_tooltip` / `.save_tooltip` | a `bonfire` button's hover tooltip (spec-0078, only if set) |
//! | `fx.…​.sealed_hint` | a `close-gate`'s authored answer to a right-click on the seal (v0.8, only if set) |
//! | `lethal.<volume>.message` | a stage-5 lethal volume's death wording (v0.10) |
//! | `state.<datum>.name` | a runtime datum's player-visible name — a currency (v0.10) |
//! | `shop.<shop>.title` | a stage-5 shop dialog's title (v0.10) |
//! | `shop.<shop>.offer.<i>.label` | a shop button's caption (v0.10) |
//! | `shop.<shop>.offer.<i>.tooltip` | a shop button's hover tooltip (v0.10) |
//! | `stake.<stake>.collected` | what collecting a recovery stake says (v0.10) |
//!
//! ## Nested effects (DSL v0.6)
//!
//! Effect strings nested inside a `sequence` step or a lifecycle bundle
//! (`on_respawn`/`on_caught`/`on_arrive`) are player-visible too, so they are
//! inventoried under **position-derived** child keys: the parent effect's `fx.…`
//! key, then a stable segment ([`crate::QuestEffect::nested_effect_lists_keyed_mut`])
//! — `seq.<step>` for a sequence step, `respawn`/`caught`/`arrive` for the bundles —
//! then the effect's index in that list, then the leaf (`.narrate`/`.give`).
//! Example: a narrate in sequence step 1, effect 0 of `on_objective_complete`
//! effect 0 → `fx.<quest>.oc.<obj>.0.seq.1.0.narrate`. Nesting is arbitrary-depth
//! (a `move-actor.on_arrive` inside a `sequence` step nests both segments). Keys are
//! purely position-derived → deterministic and stable across builds (ADR-0006).
//!
//! ## Entity display names are keyed by their TEXT, not by their site
//!
//! An NPC (`npc.<npc>.name`) and a scripted actor (`actor.<actor>.name`) are two
//! DSL surfaces for the same thing a player reads: a nameplate over a body. One
//! character routinely occupies both — a stage-2 NPC that stands and talks, plus
//! one actor puppet per cutscene pose it is staged in. If each site owned its own
//! key, a translator would be asked for `Polyphemus` five times and could answer
//! differently each time, and the giant's name would **change as he walked into a
//! cutscene** — a worse defect than the untranslated one, and an authored one.
//!
//! So the key of an entity display name is decided by its **canonical English
//! text**: the first site (in this traversal's fixed order — NPCs before actors)
//! declaring a given name owns the key, and every later site carrying the
//! byte-identical name emits that same key. The inventory therefore asks for each
//! distinct name exactly once, and two bodies a player reads as one character
//! cannot render as two.
//!
//! Scope is deliberately the **entity display-name class only** (`npc.*.name`,
//! `actor.*.name`). Prose — a narrate line, a dialogue label, an objective title —
//! is context-bound and keeps one key per site: two English strings that happen to
//! coincide may legitimately need different renderings. Wave-mob names
//! (`wave.*.mob.*.name`) are the same shape and are **not** merged here: that is a
//! generalization beyond the finding this rule closes, and it is an owner call
//! because it retires keys live campaigns already translate.
//!
//! Player-visible strings only. Deliberately **excluded** (authoring context the
//! player never sees, so translating them is pointless and out of scope): world
//! `theme`/`premise`, NPC `persona` fields, persona `relationships`.

use crate::Verb;
use std::collections::{BTreeMap, BTreeSet};

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::diagnostic::{Diagnostic, codes};
use crate::envelope::{Campaign, DSL_VERSION};
use crate::ids::CampaignId;
use crate::{NarrateStyle, QuestEffect};

/// Walk the player-visible strings of a single quest effect (DSL v0.4): a
/// `narrate` line and a named `give-item`'s display name. `keybase` is the
/// effect's stable position-derived key prefix.
fn effect_strings(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
    match &mut eff.verb {
        Verb::Narrate { text, .. } => f(&format!("{keybase}.narrate"), text),
        Verb::GiveItem { name: Some(n), .. } => f(&format!("{keybase}.give"), n),
        // spec-0016 §1: the bonfire's rest dialog is
        // read by the player like any other on-screen line, so its authored
        // strings translate like any other. Unauthored fields are absent from the
        // inventory — the compiler bakes its canonical English, exactly as
        // `world.boundary.message` does.
        Verb::Bonfire {
            prompt,
            rest_label,
            save_label,
            rest_tooltip,
            save_tooltip,
            ..
        } => {
            if let Some(p) = prompt.as_mut() {
                f(&format!("{keybase}.rest_prompt"), p);
            }
            if let Some(r) = rest_label.as_mut() {
                f(&format!("{keybase}.rest_label"), r);
            }
            if let Some(s) = save_label.as_mut() {
                f(&format!("{keybase}.save_label"), s);
            }
            // spec-0078: a button's hover tooltip is read like its label.
            if let Some(t) = rest_tooltip.as_mut() {
                f(&format!("{keybase}.rest_tooltip"), t);
            }
            if let Some(t) = save_tooltip.as_mut() {
                f(&format!("{keybase}.save_tooltip"), t);
            }
        }
        // DSL v0.8: what a sealed gate answers when the party right-clicks it. Read
        // off the actionbar exactly like a `narrate`, so it translates like one. An
        // unauthored hint is absent from the inventory — the compiler bakes its
        // canonical English, exactly as `world.boundary.message` does.
        Verb::CloseGate {
            sealed_hint: Some(h),
            ..
        } => f(&format!("{keybase}.sealed_hint"), h),
        _ => {}
    }
}

/// Walk the player-visible strings of `eff` **and every effect nested inside it**
/// (DSL v0.6): a `narrate`/`give-item` inside a `sequence` step or an
/// `on_respawn`/`on_caught`/`on_arrive` bundle is player-visible and must enter the
/// inventory (and be localized on the emission path), else it ships English-only in
/// a translated build. Child keys extend `keybase` with the effect's stable key
/// segment ([`QuestEffect::nested_effect_lists_keyed_mut`]) and the effect's index
/// within that list, e.g. `<keybase>.seq.<step>.<j>.narrate` for a narrate in
/// sequence step `<step>`, effect `<j>`. Deterministic and stable across builds.
fn effect_strings_deep(eff: &mut QuestEffect, keybase: &str, f: &mut dyn FnMut(&str, &mut String)) {
    effect_strings(eff, keybase, f);
    for (seg, list) in eff.nested_effect_lists_keyed_mut() {
        for (j, inner) in list.iter_mut().enumerate() {
            effect_strings_deep(inner, &format!("{keybase}.{seg}.{j}"), f);
        }
    }
}

/// The implicit, always-canonical language. Never appears in `world.languages`
/// and never has a sidecar; `delvec build --lang en` emits the pure-English delve.
pub const CANONICAL_LANG: &str = "en";

/// The reserved sigil opening the compiler's machine-readable **completion-marker**
/// channel — `[dw:complete <campaign_id> <token>]`, the only evidence the
/// validation bot accepts that an objective (or the campaign) actually completed.
/// The channel rides chat, so any authored or translated player-visible string
/// carrying this sigil could forge a completion and make a critical-path step pass
/// hollow. [`validate_marker_channel`] (`DW0182`) reserves it, making the collision
/// structurally impossible instead of merely implausible.
pub const MARKER_SIGIL: &str = "[dw:complete";

/// Reserve the machine completion-marker channel (`DW0182`): no player-visible
/// string — authored English (the whole [`inventory`]) or any declared language's
/// sidecar rendition — may contain [`MARKER_SIGIL`]. Language-independent; runs on
/// every `validate` / `analyze` / `build`. Checks translations too: a translator
/// (LLM or human) copying the sigil through is exactly the forgery this closes.
pub fn validate_marker_channel(
    c: &Campaign,
    sidecars: &BTreeMap<String, L10nDoc>,
) -> Vec<Diagnostic> {
    let mut d = Vec::new();
    let mut flag = |where_: String, key: &str, text: &str| {
        if text.contains(MARKER_SIGIL) {
            d.push(Diagnostic::error(
                codes::MARKER_RESERVED,
                "l10n",
                where_,
                format!(
                    "player-visible string `{key}` contains the reserved completion-marker \
                     sigil `{MARKER_SIGIL}` — that chat sequence is the validation bot's \
                     completion oracle, and authored text carrying it could forge a passing \
                     critical-path step. Reword the line to drop `{MARKER_SIGIL}`"
                ),
            ));
        }
    };
    for (key, text) in inventory(c) {
        flag(format!("#/{key}"), &key, &text);
    }
    for (lang, doc) in sidecars {
        for (key, text) in &doc.content {
            flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
        }
    }
    d
}

/// The kind marker on an l10n sidecar envelope (`"l10n"`), analogous to the stage
/// marker on a stage document.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum L10nKind {
    /// A localization sidecar (`l10n/<code>.json`).
    L10n,
}

/// An l10n sidecar document: `{ dsl_version, campaign_id, kind: "l10n", lang,
/// content, source }`, mirroring the stage-doc envelope style. `content` is a flat
/// map of [inventory](crate::l10n::inventory) key → translated string; `source`
/// records the canonical English each of those translations was made **from**.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct L10nDoc {
    /// DSL version string (same versioning as the stage docs).
    pub dsl_version: String,
    /// Owning campaign id (must match the stage docs).
    pub campaign_id: CampaignId,
    /// Document kind marker (`l10n`).
    pub kind: L10nKind,
    /// The BCP-47-style language code this sidecar translates into (must match the
    /// `l10n/<code>.json` filename and appear in `world.languages`).
    pub lang: String,
    /// Flat map of inventory key → translated string.
    pub content: BTreeMap<String, String>,
    /// **Translation provenance**: inventory key → the canonical English that key
    /// held when its [`Self::content`] row was written.
    ///
    /// Coverage validation proves the sidecar has a row for every key
    /// (`DW0180`/`DW0181`), which is a statement about key SETS and says nothing
    /// about whether a row still corresponds to the English it renders. Edit an
    /// authored line and its translation is stale, present, applied, and wrong —
    /// and nothing in the key sets moved. `source` is what makes that
    /// **detectable** ([`validate_l10n_provenance`], `DW0187`) instead of audited.
    ///
    /// This is load-bearing for entity display names in particular, because their
    /// key is owned by the first site declaring a given text (see the module
    /// header): renaming ONE body can migrate a key's ownership to ANOTHER body,
    /// so the row that goes wrong is not the row the author touched.
    ///
    /// Optional in the format — an older sidecar parses unchanged and simply
    /// carries no provenance, which `DW0188` reports as an unguarded row count on
    /// every run rather than letting it pass in silence.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub source: BTreeMap<String, String>,
}

/// The local part of a type-prefixed id: the segment after the first `/` (kebab
/// preserved). `npc/keeper` → `keeper`. Ids without a `/` pass through unchanged.
fn local(id: &str) -> &str {
    id.split_once('/').map(|(_, r)| r).unwrap_or(id)
}

/// The local part of a type-prefixed DSL id, exactly as the key scheme derives it
/// (`npc/keeper` → `keeper`). Public so a consumer that pairs inventory keys back
/// with their source objects (`delvec l10n-inventory`, matching `dlg.<npc>.…` keys
/// to the NPC that speaks them) derives the same segment the keys are built from.
pub fn local_id(id: &str) -> &str {
    local(id)
}

/// Walk every player-visible string in `c` in a fixed, deterministic order,
/// invoking `f(key, &mut value)` for each. The single traversal shared by
/// [`inventory`] and [`localize`] — they cannot drift.
pub fn each_string(c: &mut Campaign, f: &mut dyn FnMut(&str, &mut String)) {
    // Entity display names (a nameplate over a body) are keyed by their canonical
    // English TEXT, not by their declaration site: canonical English → owning key.
    // See the module header — one character routinely has one NPC identity and
    // several actor puppets, and a per-site key would let its name be translated
    // several ways. Filled in traversal order, so the NPC identity always owns the
    // key and the puppets follow it.
    let mut entity_names: BTreeMap<String, String> = BTreeMap::new();
    // Stage 1 — world title + area names.
    f("world.title", &mut c.world.content.title);
    for area in &mut c.world.content.areas {
        let key = format!("area.{}.name", local(area.id.as_str()));
        f(&key, &mut area.name);
    }
    // Stage 1 — boundary return message (v0.6, only when authored). The compiler's
    // default English is baked at emit time, so it is not inventoried; an authored
    // message is translated like every other player-facing string.
    if let Some(b) = c.world.content.boundary.as_mut()
        && let Some(msg) = b.message.as_mut()
    {
        f("world.boundary.message", msg);
    }
    // Stage 1 — campaign outro (v0.6, only when authored): the closing line on the
    // completion advancement. Unauthored, the emitter falls back to the finale
    // quest's `goal`, which is inventoried in its own right — so the last sentence
    // of a delve is campaign-derived and translated either way.
    if let Some(outro) = c.world.content.outro.as_mut() {
        f("world.outro", outro);
    }
    // Stage 3 — class names/blurbs + optional kit item display names.
    for class in &mut c.classes.content.classes {
        let cl = local(class.id.as_str()).to_string();
        f(&format!("class.{cl}.name"), &mut class.name);
        f(&format!("class.{cl}.blurb"), &mut class.blurb);
        for (i, item) in class.kit.iter_mut().enumerate() {
            if let Some(name) = item.name.as_mut() {
                f(&format!("class.{cl}.kit.{i}.name"), name);
            }
        }
    }
    // Stage 2 — NPC names. First in the entity display-name class, so an NPC
    // identity owns the key every actor puppet portraying it shares.
    for npc in &mut c.npcs.content.npcs {
        let key = entity_name_key(
            &mut entity_names,
            &npc.name,
            format!("npc.{}.name", local(npc.id.as_str())),
        );
        f(&key, &mut npc.name);
    }
    // Stage 4 — quest goals.
    for q in &mut c.quest_plan.content.quests {
        let key = format!("quest.{}.goal", local(q.id.as_str()));
        f(&key, &mut q.goal);
    }
    // Stage 5 — objective titles/hints (when set).
    for q in &mut c.quests.content.quests {
        let ql = local(q.id.as_str()).to_string();
        for o in &mut q.objectives {
            let ol = local(o.id().as_str()).to_string();
            if let Some(title) = o.title_mut().as_mut() {
                f(&format!("obj.{ql}.{ol}.title"), title);
            }
            if let Some(hint) = o.hint_mut().as_mut() {
                f(&format!("obj.{ql}.{ol}.hint"), hint);
            }
            // Stage 5 — v0.7 `interact.missing_item_hint`: narrated in chat to the
            // player who clicks without the required item in hand, so it is as
            // player-visible as `hint` and translates like it. Absent on every
            // pre-0.7 objective → inventory unchanged.
            if let crate::Objective::Interact {
                missing_item_hint: Some(m),
                ..
            } = o
            {
                f(&format!("obj.{ql}.{ol}.missing_item_hint"), m);
            }
            // Stage 5 — v0.8 `collect.item_name`: the display name the
            // collected item carries as a `custom_name` component. A player reads
            // it off the stack in the barrel and off their own hotbar, so it is as
            // player-visible as a `title` and translates like one. Absent on every
            // pre-0.8 objective → inventory unchanged.
            if let crate::Objective::Collect {
                item_name: Some(n), ..
            } = o
            {
                f(&format!("obj.{ql}.{ol}.item_name"), n);
            }
        }
        // Stage 5 — v0.7 cast-ledger bark lines (spec-0020). Barks are spoken
        // in-game exactly like narrate text, so they translate like it too.
        // `doing` is deliberately NOT inventoried: it is authoring context for
        // the dialogue stage, never shown to a player. (`cast` is a BTreeMap and
        // the placement list is ordered, so the traversal stays deterministic.)
        for (npc, entry) in &mut q.cast {
            let np = local(npc.as_str()).to_string();
            for (b, p) in entry.placements_mut().into_iter().enumerate() {
                let Some(crate::CastDialogue::Barks(pool)) = p.dialogue.as_mut() else {
                    continue;
                };
                for (i, line) in pool.barks.iter_mut().enumerate() {
                    f(&format!("cast.{ql}.{np}.{b}.bark.{i}"), line);
                }
            }
        }
    }
    // Stage 6 — dialogue node text + option labels.
    for tree in &mut c.dialogue.content.dialogues {
        let np = local(tree.npc.as_str()).to_string();
        for node in &mut tree.nodes {
            let nd = local(node.id.as_str()).to_string();
            f(&format!("dlg.{np}.{nd}.text"), &mut node.text);
            for (i, opt) in node.options.iter_mut().enumerate() {
                f(&format!("dlg.{np}.{nd}.opt.{i}.label"), &mut opt.label);
                // v0.8: the button's hover tooltip. A player reads it exactly as
                // they read the caption, so it translates exactly like one; an
                // unauthored tooltip is absent from the inventory (no key, no
                // coverage obligation), like every other `only if set` string.
                if let Some(tip) = opt.tooltip.as_mut() {
                    f(&format!("dlg.{np}.{nd}.opt.{i}.tooltip"), tip);
                }
            }
        }
    }
    // Stage 5 — wave mob custom names (when set).
    for w in &mut c.quests.content.waves {
        let wl = local(w.id.as_str()).to_string();
        for (i, mob) in w.mobs.iter_mut().enumerate() {
            if let Some(name) = mob.name.as_mut() {
                f(&format!("wave.{wl}.mob.{i}.name"), name);
            }
            // Stage 5 — v0.9 declared quest-item drops. The name
            // rides the dropped stack's `custom_name`, so the player reads it off
            // the ground and off their own hotbar: as player-visible as a wave
            // mob's own name, and translated like one.
            for (n, dr) in mob.drops.iter_mut().enumerate() {
                if let Some(name) = dr.name_mut() {
                    f(&format!("wave.{wl}.mob.{i}.drop.{n}.name"), name);
                }
            }
        }
        // Stage 5 — a wave's stated health-bar title (DSL v0.31, spec-0073). A
        // DERIVED title is not a string of its own: it is the one mob entry's
        // name, emitted under that name's key, so the character is translated
        // once and the bar cannot call it something else.
        if let Some(t) = w.health_bar.as_mut().and_then(|b| b.title.as_mut()) {
            f(&format!("wave.{wl}.health_bar.title"), t);
        }
    }
    // Stage 5 — actors: the nameplate over the puppet, then its v0.9 drops,
    // keyed off the actor id exactly as a wave mob's drop is keyed off its
    // wave.
    for a in &mut c.quests.content.actors {
        let al = local(a.id.as_str()).to_string();
        // The puppet's own name (v0.6 `actors[].name`). Player-visible in every
        // frame it stands in — a nameplate and, for a cutscene mannequin, the
        // label the party reads while the scene plays — so it is as translatable
        // as the stage-2 NPC name it usually duplicates, and shares that NPC's key
        // when the two texts are identical (module header).
        //
        // `ACTOR_NAME_ENTRY` carries the whole fence: the field is v0.6 but the
        // walk only reached it in the 0.10 era, so the coverage obligation is
        // 0.10's and not v0.6's.
        if let Some(name) = a.name.as_mut() {
            let key = entity_name_key(&mut entity_names, name, format!("actor.{al}.name"));
            f(&key, name);
        }
        for (n, dr) in a.drops.iter_mut().enumerate() {
            if let Some(name) = dr.name_mut() {
                f(&format!("actor.{al}.drop.{n}.name"), name);
            }
        }
        // An actor's stated health-bar title (spec-0073); a derived one is the
        // actor's `name`, under whatever key that name is inventoried.
        if let Some(t) = a.health_bar.as_mut().and_then(|b| b.title.as_mut()) {
            f(&format!("actor.{al}.health_bar.title"), t);
        }
    }
    // Stage 5 — loot item custom names (spec-0021), keyed like a class kit
    // item's name so a named prop in a chest translates like any other.
    for l in &mut c.quests.content.loot {
        let ll = local(l.id.as_str()).to_string();
        for (i, item) in l.items.iter_mut().enumerate() {
            if let Some(name) = item.name.as_mut() {
                f(&format!("loot.{ll}.item.{i}.name"), name);
            }
        }
    }
    // Stage 5 — lethal volumes (v0.10, spec-0031): the line the volume says as it
    // kills. As player-visible as a narrate, and read at the worst possible moment
    // to be reading a raw key, so it is inventoried like any other authored line.
    for v in &mut c.quests.content.lethal_volumes {
        let vl = local(v.id.as_str()).to_string();
        f(&format!("lethal.{vl}.message"), &mut v.message);
    }
    // Stage 5 — a runtime datum's player-visible name (v0.10, spec-0032). A named
    // datum is a currency: the engine states `<name>: <value>` on the holder's
    // action bar on every write, so the name is read as often as any narrate.
    for st in &mut c.quests.content.state {
        let sl = local(st.id.as_str()).to_string();
        if let Some(name) = st.name.as_mut() {
            f(&format!("state.{sl}.name"), name);
        }
    }
    // Stage 5 — shops (v0.10, spec-0032): the dialog's title, and each button's
    // caption and tooltip. Keyed exactly as a dialogue node's label/tooltip are,
    // because they are the same two components of the same vanilla button codec.
    for sh in &mut c.quests.content.shops {
        let hl = local(sh.id.as_str()).to_string();
        f(&format!("shop.{hl}.title"), &mut sh.title);
        for (i, off) in sh.offers.iter_mut().enumerate() {
            f(&format!("shop.{hl}.offer.{i}.label"), &mut off.label);
            if let Some(t) = off.tooltip.as_mut() {
                f(&format!("shop.{hl}.offer.{i}.tooltip"), t);
            }
        }
    }
    // Stage 5 — recovery stakes (v0.10, spec-0032): the line a collection says.
    for st in &mut c.quests.content.stakes {
        let sl = local(st.id.as_str()).to_string();
        f(&format!("stake.{sl}.collected"), &mut st.collected_message);
    }
    // v0.4 effect strings — `narrate` text, a named `give-item`, a bonfire's rest
    // dialog, a seal's answer — over **every** root emission can lower an effect
    // from, not just the quests stage's three ([`effect_roots_mut`]).
    // Nesting inside each root is descended by `effect_strings_deep`, so a narrate
    // in a `sequence` step of a trap payload is inventoried like any other.
    for (_stage, _path, keybase, eff) in effect_roots_mut(c) {
        effect_strings_deep(eff, &keybase, f);
    }
}

/// The key an entity display name is inventoried under: the key already claimed by
/// an identical name earlier in the traversal, or `own` if this site is the first
/// to carry that text (in which case it claims it for every later site).
///
/// The lookup is on the string as authored, captured **before** `f` may rewrite it
/// ([`localize`] swaps the NPC's name to the target language, and the actor puppets
/// that follow are still English at the moment they are looked up).
fn entity_name_key(claimed: &mut BTreeMap<String, String>, text: &str, own: String) -> String {
    claimed.entry(text.to_string()).or_insert(own).clone()
}

/// The authoritative key → canonical-English inventory derived from the stage
/// docs. Deterministic (keys are unique and the traversal order is fixed).
///
/// # Widening this is an ERROR-tier obligation on every existing campaign
///
/// This walk consults the campaign document and **never its `dsl_version`**.
/// For the surface itself that is right — a campaign at 0.6.0 cannot use a 0.9
/// surface, so those keys simply never appear — but it has a consequence worth
/// stating, and one already paid for twice — by the widenings of
/// [`each_string`] onto `traps[].payload` and onto `on_respawn` bundles:
///
/// **when a widening reaches strings that OLDER surfaces already emitted**, the
/// inventory grows for campaigns of every declared version at once, and
/// [`L10N_MISSING`](crate::codes::L10N_MISSING) (`DW0180`) is
/// `Diagnostic::error`. A campaign that was complete and green stops building
/// on the next engine, with no deprecation window and nothing in its own
/// document changed.
///
/// Measured 2026-08-08 on `nobodys-cave-island` (sidecar `dsl_version` 0.6.0):
/// removing one key — the shape a widening creates — exits 1 immediately with
/// `DW0180 [error]`.
///
/// **The asymmetry is the finding, and it is with this module's own siblings.**
/// One comparable obligation on existing content takes a warn-first window and
/// says so in its own text: `DW0188` (translation provenance, in
/// [`validate_l10n_provenance`] right here). Coverage does not. Whether it should is an owner call —
/// changing a check's tier is never a mechanical change (CLAUDE.md) — so this
/// records the measurement rather than acting on it.
///
/// Whichever way it is decided, the widening PR is where it has to be decided:
/// adoption rounds for every active campaign belong in the same milestone as
/// the `dsl_version` that creates the obligation, and a widening that skips the
/// version bump creates the obligation with no milestone at all.
pub fn inventory(c: &Campaign) -> BTreeMap<String, String> {
    let mut c2 = c.clone();
    let mut out = BTreeMap::new();
    each_string(&mut c2, &mut |key, value| {
        out.insert(key.to_string(), value.clone());
    });
    out
}

/// The NPC an inventory key belongs to (its **local** id), when the key scheme
/// encodes one: `dlg.<npc>.…` (that NPC's dialogue tree — `.text` is the NPC's own
/// line, `.opt.<i>.label` the player's reply *within* it), `npc.<npc>.name`, and
/// `cast.<quest>.<npc>.…` (a v0.7 bark line — the NPC's own murmured speech, so a
/// translator gets the same persona context a dialogue line gets).
/// Returns `None` for every other key kind.
///
/// Lives beside [`each_string`] — the traversal that *defines* the key scheme — so
/// the two cannot drift silently (a CLI test asserts every speaker derived from a
/// real campaign's inventory resolves to a declared NPC). Consumed by
/// `delvec l10n-inventory`, which hands a translator the speaking character's
/// persona (`speech_style` above all) alongside the English line.
pub fn key_speaker(key: &str) -> Option<&str> {
    let (kind, rest) = key.split_once('.')?;
    match kind {
        "dlg" | "npc" => rest.split('.').next(),
        // `cast.<quest>.<npc>.<branch>.bark.<i>` — the npc is the second segment.
        "cast" => rest.split('.').nth(1),
        _ => None,
    }
}

/// What kind of player-facing text an inventory key holds — the class a writer
/// (or a transcreating model) needs before the English, because each class has
/// its own job (`docs/reference/game-writing.md` §1–§2).
///
/// Derived from the key alone, beside [`key_speaker`] and the traversal that
/// defines the key scheme ([`each_string`]); a CLI test asserts every key of a
/// real campaign's inventory resolves to a kind, so a key the scheme grows that
/// this function does not know is a red test rather than an unlabelled row.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextKind {
    /// A proper name on a body, place, class, counter or the world itself.
    Name,
    /// A heading: a health bar's or shop dialog's title, the world title.
    Title,
    /// A class blurb: what the role is.
    Description,
    /// An objective title or hint, or a quest goal: tells the player what to do.
    Objective,
    /// A message shown when an action fails: says what is wrong and what fixes it.
    Refusal,
    /// An NPC's line in a dialogue tree.
    Dialogue,
    /// An NPC's murmured line in passing (a cast bark).
    Bark,
    /// The caption on a fixed-width button: the player's own words.
    OptionLabel,
    /// The hover text of a dialog button (a dialogue option, or a bonfire's rest
    /// or save): the consequence of choosing it.
    ButtonTooltip,
    /// An item's display name.
    ItemName,
    /// The hover text of a shop offer: what the item does.
    ItemTooltip,
    /// A dialog's prompt (a bonfire's rest question).
    Prompt,
    /// Narration in chat, on screen or on the action bar.
    Narration,
}

/// The [`TextKind`] of an inventory key, or `None` for a key the scheme does not
/// define. Pure on the key, like [`key_speaker`].
pub fn key_kind(key: &str) -> Option<TextKind> {
    use TextKind::*;
    let segs: Vec<&str> = key.split('.').collect();
    let first = *segs.first()?;
    let last = *segs.last()?;
    let n = segs.len();
    Some(match first {
        "world" => match key {
            "world.title" => Title,
            "world.boundary.message" => Refusal,
            "world.outro" => Narration,
            _ => return None,
        },
        "area" | "npc" | "state" if n == 3 && last == "name" => Name,
        "class" => match (n, last) {
            (3, "name") => Name,
            (3, "blurb") => Description,
            (5, "name") if segs[2] == "kit" => ItemName,
            _ => return None,
        },
        "quest" if n == 3 && last == "goal" => Objective,
        "obj" if n == 4 => match last {
            "title" | "hint" => Objective,
            "missing_item_hint" => Refusal,
            "item_name" => ItemName,
            _ => return None,
        },
        "cast" if n == 6 && segs[4] == "bark" => Bark,
        "dlg" => match (n, last) {
            (4, "text") => Dialogue,
            (6, "label") if segs[3] == "opt" => OptionLabel,
            (6, "tooltip") if segs[3] == "opt" => ButtonTooltip,
            _ => return None,
        },
        "wave" | "actor" => match last {
            "name" if segs.contains(&"drop") => ItemName,
            "name" => Name,
            "title" if segs[n - 2] == "health_bar" => Title,
            _ => return None,
        },
        "loot" if n == 5 && last == "name" => ItemName,
        "lethal" if n == 3 && last == "message" => Narration,
        "stake" if n == 3 && last == "collected" => Narration,
        "shop" => match (n, last) {
            (3, "title") => Title,
            (5, "label") => OptionLabel,
            (5, "tooltip") => ItemTooltip,
            _ => return None,
        },
        "fx" => match last {
            "narrate" => Narration,
            "give" => ItemName,
            "rest_prompt" => Prompt,
            "rest_label" | "save_label" => OptionLabel,
            // spec-0078: a bonfire's buttons carry hover text like every other
            // dialog button.
            "rest_tooltip" | "save_tooltip" => ButtonTooltip,
            "sealed_hint" => Refusal,
            _ => return None,
        },
        _ => return None,
    })
}

/// The situation each inventory key is said in: the lines of campaign context a
/// writer needs to write it from its intent rather than from its words — the
/// quest it belongs to, the objective, what the beat does to the story (its
/// `happening`), what the speaker is doing, the NPC line an option answers.
///
/// A procedural derivation from the stage documents, keyed exactly like
/// [`inventory`]; a key with nothing to add maps to an empty list. Consumed by
/// `delvec l10n-inventory`, which hands it to a transcreator beside the English
/// (`tools/creator/i18n-translate.py`). Authoring context the player never sees
/// (`happening.text`, a cast placement's `doing`) is exactly what this carries.
pub fn key_situations(c: &Campaign) -> BTreeMap<String, Vec<String>> {
    let goals: BTreeMap<&str, &str> = c
        .quest_plan
        .content
        .quests
        .iter()
        .map(|q| (local(q.id.as_str()), q.goal.as_str()))
        .collect();
    let quests: BTreeMap<&str, &crate::Quest> = c
        .quests
        .content
        .quests
        .iter()
        .map(|q| (local(q.id.as_str()), q))
        .collect();
    let npc_names: BTreeMap<&str, &str> = c
        .npcs
        .content
        .npcs
        .iter()
        .map(|n| (local(n.id.as_str()), n.name.as_str()))
        .collect();
    let mut nodes: BTreeMap<(&str, &str), &crate::DialogueNode> = BTreeMap::new();
    for tree in &c.dialogue.content.dialogues {
        for node in &tree.nodes {
            nodes.insert((local(tree.npc.as_str()), local(node.id.as_str())), node);
        }
    }
    let shops: BTreeMap<&str, &crate::Shop> = c
        .quests
        .content
        .shops
        .iter()
        .map(|s| (local(s.id.as_str()), s))
        .collect();
    let mut beats: BTreeMap<String, &str> = BTreeMap::new();
    each_effect_ref(c, &mut |_stage, _path, keybase, eff| {
        if let Some(h) = &eff.happening {
            beats.insert(keybase.to_string(), h.text.as_str());
        }
    });

    let objective = |q: &str, o: &str| {
        quests
            .get(q)
            .and_then(|q| q.objectives.iter().find(|x| local(x.id().as_str()) == o))
    };
    let quest_lines = |q: &str, out: &mut Vec<String>, own_goal: bool| {
        if !own_goal && let Some(g) = goals.get(q) {
            out.push(format!("Quest: {g}"));
        }
        if let Some(h) = quests.get(q).and_then(|x| x.happening.as_ref()) {
            out.push(format!("What the quest does to the story: {}", h.text));
        }
    };
    let option_lines = |np: &str, nd: &str, oi: &str, out: &mut Vec<String>, own: &str| {
        let Some(node) = nodes.get(&(np, nd)) else {
            return;
        };
        out.push(format!("Answers the NPC line: {}", node.text));
        let Some(opt) = oi.parse::<usize>().ok().and_then(|i| node.options.get(i)) else {
            return;
        };
        if own != "label" {
            out.push(format!("Caption on the button: {}", opt.label));
        }
        if own == "label"
            && let Some(t) = &opt.tooltip
        {
            out.push(format!("Full line in the tooltip: {t}"));
        }
        if let Some(h) = &opt.happening {
            out.push(format!("Choosing it: {}", h.text));
        }
    };

    let mut out = BTreeMap::new();
    for key in inventory(c).into_keys() {
        let segs: Vec<&str> = key.split('.').collect();
        let mut lines: Vec<String> = Vec::new();
        match segs.as_slice() {
            ["world", "boundary", "message"] => {
                lines.push("Shown when a player walks past the edge of the map.".into())
            }
            ["world", "outro"] => {
                lines.push("The closing line, shown when the delve is complete.".into())
            }
            ["class", cl, "blurb"] | ["class", cl, "kit", _, "name"] => {
                if let Some(class) = c
                    .classes
                    .content
                    .classes
                    .iter()
                    .find(|x| local(x.id.as_str()) == *cl)
                {
                    lines.push(format!("Class: {}", class.name));
                }
            }
            ["quest", q, "goal"] => quest_lines(q, &mut lines, true),
            ["obj", q, o, field] => {
                quest_lines(q, &mut lines, false);
                if let Some(obj) = objective(q, o) {
                    if *field != "title"
                        && let Some(t) = obj.title()
                    {
                        lines.push(format!("Objective: {t}"));
                    }
                    if *field != "hint"
                        && let Some(h) = obj.hint()
                    {
                        lines.push(format!("Objective hint: {h}"));
                    }
                    if let Some(h) = obj.happening() {
                        lines.push(format!("Completing it: {}", h.text));
                    }
                }
            }
            ["cast", q, np, b, "bark", _] => {
                quest_lines(q, &mut lines, false);
                let doing = quests
                    .get(q)
                    .and_then(|x| {
                        x.cast
                            .iter()
                            .find(|(id, _)| local(id.as_str()) == *np)
                            .map(|(_, e)| e)
                    })
                    .and_then(|e| {
                        b.parse::<usize>()
                            .ok()
                            .and_then(|i| e.placements().get(i).copied())
                    })
                    .and_then(|p| p.doing.as_deref());
                if let Some(d) = doing {
                    let who = npc_names.get(np).copied().unwrap_or(np);
                    lines.push(format!("{who}, during this quest: {d}"));
                }
            }
            ["dlg", np, nd, "text"] => {
                if let Some(node) = nodes.get(&(*np, *nd))
                    && !node.options.is_empty()
                {
                    let labels: Vec<&str> = node.options.iter().map(|o| o.label.as_str()).collect();
                    lines.push(format!("The player can answer: {}", labels.join(" / ")));
                }
            }
            ["dlg", np, nd, "opt", oi, field] => option_lines(np, nd, oi, &mut lines, field),
            ["lethal", _, "message"] => {
                lines.push("Shown to a player as this hazard kills them.".into())
            }
            ["stake", _, "collected"] => {
                lines.push("Shown when a player picks up what they dropped at death.".into())
            }
            ["shop", h, rest @ ..] => {
                if let Some(shop) = shops.get(h) {
                    if rest != ["title"] {
                        lines.push(format!("Shop: {}", shop.title));
                    }
                    if let [_, i, "tooltip"] = rest
                        && let Some(off) = i.parse::<usize>().ok().and_then(|i| shop.offers.get(i))
                    {
                        lines.push(format!("Caption on the button: {}", off.label));
                    }
                }
            }
            ["fx", rest @ ..] => {
                match rest {
                    ["trig", ..] => lines.push("Fires from a trigger placed in the world.".into()),
                    ["trap", ..] => lines.push("Fires from a trap.".into()),
                    ["sc", ..] => lines.push("Fires when a shortcut opens.".into()),
                    ["death", ..] => lines.push("Fires when a player dies.".into()),
                    ["dlg", np, nd, oi, ..] => {
                        if let Some(opt) = nodes
                            .get(&(*np, *nd))
                            .and_then(|n| oi.parse::<usize>().ok().and_then(|i| n.options.get(i)))
                        {
                            lines.push(format!("Fires after the player chooses: {}", opt.label));
                        }
                    }
                    ["shop", h, oi, ..] => {
                        if let Some(shop) = shops.get(h)
                            && let Some(off) =
                                oi.parse::<usize>().ok().and_then(|i| shop.offers.get(i))
                        {
                            lines.push(format!(
                                "Fires after buying: {} (shop: {})",
                                off.label, shop.title
                            ));
                        }
                    }
                    [q, "oc", o, ..] => {
                        quest_lines(q, &mut lines, false);
                        if let Some(obj) = objective(q, o) {
                            let name = obj.title().unwrap_or(o);
                            lines.push(format!("Fires when this objective is done: {name}"));
                            if let Some(h) = obj.happening() {
                                lines.push(format!("Completing it: {}", h.text));
                            }
                        }
                    }
                    [q, "done", ..] => {
                        quest_lines(q, &mut lines, false);
                        lines.push("Fires when the quest completes.".into());
                    }
                    _ => {}
                }
                // The nearest enclosing effect that states a beat: the effect
                // holding this string, else the sequence or bundle around it.
                let mut base: &str = key.rsplit_once('.').map(|(b, _)| b).unwrap_or(&key);
                loop {
                    if let Some(text) = beats.get(base) {
                        lines.push(format!("This beat: {text}"));
                        break;
                    }
                    match base.rsplit_once('.') {
                        Some((up, _)) => base = up,
                        None => break,
                    }
                }
            }
            _ => {}
        }
        out.insert(key, lines);
    }
    out
}

/// One `narrate` `art` occurrence (DSL v0.6, spec-0014): its stage-doc path (for
/// diagnostics), its l10n inventory key, and the canonical English text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtNarrate {
    /// The stage document the string was authored in (`quests` / `dialogue`).
    pub stage: &'static str,
    /// JSON-pointer-ish path within that stage doc.
    pub path: String,
    /// The l10n inventory key (`fx.…​.narrate`) — always present, since every
    /// `narrate` lives in an inventoried effect position.
    pub key: String,
    /// The canonical English text.
    pub text: String,
}

/// Every `narrate` effect using the v0.6 `art` style, in a fixed deterministic
/// order. Its l10n `key` is derived by the **same** traversal/keying as
/// [`inventory`]/[`each_string`], so the compiler's art-font glyph check
/// (`DW0328`) can look each art string up in every declared-language sidecar.
/// Every art narrate lives in a quest `on_objective_complete`/`on_complete` or an
/// environment trigger — all inventoried — so each `key` is guaranteed present in
/// a fully-covered sidecar.
pub fn art_narrates(c: &Campaign) -> Vec<ArtNarrate> {
    let mut out = Vec::new();
    each_effect_ref(c, &mut |stage, path, keybase, eff| {
        if let Some(text) = eff.narrate_art_text() {
            out.push(ArtNarrate {
                stage,
                path: format!("{path}/text"),
                key: format!("{keybase}.narrate"),
                text: text.to_string(),
            });
        }
    });
    out
}

/// One on-screen `narrate` occurrence — `title`, `subtitle` or `art` — its stage-doc
/// path, its l10n inventory key, its style, and the canonical English text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScreenNarrate {
    /// The stage document the string was authored in (`quests` / `dialogue`).
    pub stage: &'static str,
    /// JSON-pointer-ish path within that stage doc.
    pub path: String,
    /// The l10n inventory key (`fx.…​.narrate`).
    pub key: String,
    /// Which on-screen channel vanilla draws it in — this selects the width budget.
    pub style: NarrateStyle,
    /// The canonical English text.
    pub text: String,
}

/// Every `narrate` effect vanilla draws **on screen** (`title` / `subtitle` / `art`),
/// in a fixed deterministic order. Like [`art_narrates`], each `key` is derived by the
/// **same** traversal/keying as [`inventory`]/[`each_string`], so the compiler's
/// text-fit check (`DW0330`) can look every string up in each declared-language
/// sidecar and report it under the offending locale and key. `chat` narrates are
/// excluded: chat wraps and scrolls, so it has no width budget.
pub fn on_screen_narrates(c: &Campaign) -> Vec<ScreenNarrate> {
    let mut out = Vec::new();
    each_effect_ref(c, &mut |stage, path, keybase, eff| {
        if let Some((style, text)) = eff.narrate_on_screen() {
            out.push(ScreenNarrate {
                stage,
                path: format!("{path}/text"),
                key: format!("{keybase}.narrate"),
                style,
                text: text.to_string(),
            });
        }
    });
    out
}

/// One dialogue option label — the caption vanilla draws on a fixed-width dialog
/// button — with its stage-doc path, its l10n inventory key and the canonical
/// English text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OptionLabel {
    /// The stage document the string was authored in (`dialogue` for a real
    /// dialogue option; a bonfire's labels carry the stage they were authored in).
    pub stage: &'static str,
    /// JSON-pointer-ish path within that stage doc.
    pub path: String,
    /// The l10n inventory key (`dlg.<npc>.<node>.opt.<i>.label`).
    pub key: String,
    /// The canonical English text.
    pub text: String,
}

/// Every dialogue option label, in a fixed deterministic order (declaration order:
/// tree, then node, then option). Each `key` is derived by the **same** keying as
/// [`inventory`]/[`each_string`], so the compiler's button-width check (`DW0331`)
/// can look every label up in each declared-language sidecar and report an
/// overflowing translation under its own locale and key.
///
/// Every option label is emitted as a button caption exactly once per node variant
/// (`emit::build_node_dialog`); display gating (`requires_flags`/`forbids_flags`)
/// only decides *whether* a variant shows it, never how wide it renders, so gated
/// and ungated options carry the same budget and are all visited here.
pub fn dialogue_option_labels(c: &Campaign) -> Vec<OptionLabel> {
    let mut out = Vec::new();
    for (ti, tree) in c.dialogue.content.dialogues.iter().enumerate() {
        let np = local(tree.npc.as_str());
        for (ni, node) in tree.nodes.iter().enumerate() {
            let nd = local(node.id.as_str());
            for (oi, opt) in node.options.iter().enumerate() {
                out.push(OptionLabel {
                    stage: "dialogue",
                    path: format!("/content/dialogues/{ti}/nodes/{ni}/options/{oi}/label"),
                    key: format!("dlg.{np}.{nd}.opt.{oi}.label"),
                    text: opt.label.clone(),
                });
            }
        }
    }
    out
}

/// Every **authored** bonfire rest-dialog label (spec-0016 §1), in the same
/// fixed effect order the inventory uses. A bonfire's
/// two options are drawn on exactly the same 150-GUI-px `multi_action` button a
/// dialogue option is, so they carry exactly the same width budget (`DW0331`) —
/// the check follows the widget, not the stage the string was authored in.
///
/// Unauthored labels are absent by construction: the compiler's canonical English
/// (`Rest and save` / `Save only` / `Bonfire`) is measured once by a compiler unit
/// test rather than re-measured per campaign, since it cannot vary.
pub fn bonfire_option_labels(c: &Campaign) -> Vec<OptionLabel> {
    let mut out = Vec::new();
    each_effect_ref(c, &mut |stage, path, keybase, eff| {
        let Some(l) = eff.bonfire_labels() else {
            return;
        };
        for (text, field, key) in [
            (l.rest_label, "rest_label", "rest_label"),
            (l.save_label, "save_label", "save_label"),
        ] {
            if let Some(text) = text {
                out.push(OptionLabel {
                    stage,
                    path: format!("{path}/{field}"),
                    key: format!("{keybase}.{key}"),
                    text: text.to_string(),
                });
            }
        }
    });
    out
}

/// The **five effect roots** the compiler can lower a quest effect from, as
/// `(stage, json_path, l10n keybase)` for each root's `i`-th top-level effect.
///
/// The roots themselves are **not enumerated here**. They come from
/// [`crate::effects::for_each_effect_root`], the one enumeration in the workspace,
/// which this walk simply indexes into per top-level effect (`path` + `/{i}`,
/// `key` + `.{i}`). Before that module existed this function held its own copy of
/// the root list and [`effect_roots_mut`] held a second one, which is the
/// arrangement that let a walk go blind to a root — twice, independently, in this
/// file alone. The paths and keys are unchanged in both directions.
fn effect_roots(c: &Campaign) -> Vec<EffectRoot<'_>> {
    let mut out = Vec::new();
    crate::effects::for_each_effect_root(c, &mut |site, list| {
        for (i, eff) in list.iter().enumerate() {
            out.push(EffectRoot {
                stage: site.stage,
                path: format!("{}/{i}", site.path),
                key: format!("{}.{i}", site.key),
                eff,
            });
        }
    });
    out
}

/// One top-level effect root: where it lives, what its l10n keys hang off, and the
/// effect itself.
struct EffectRoot<'a> {
    /// The stage document (`quests` / `dialogue`) this effect was authored in.
    stage: &'static str,
    /// JSON pointer to the effect within that document.
    path: String,
    /// The effect's l10n key prefix.
    key: String,
    /// The effect.
    eff: &'a QuestEffect,
}

/// The **mutable mirror** of [`effect_roots`]: the identical roots, in the
/// identical order, with the same `(stage, path, key)` descriptors, exposed
/// mutably so [`each_string`] (and through it [`localize`]) can rewrite the
/// player-visible strings in place.
///
/// Like [`effect_roots`] it enumerates nothing itself — it indexes
/// [`crate::effects::for_each_effect_root_mut`], which is generated from the
/// **same macro body** as the immutable walk. The two mirrors are therefore
/// lockstep by construction rather than by a test that has to be remembered; the
/// descriptor-equality test below now pins that property instead of establishing
/// it.
fn effect_roots_mut(c: &mut Campaign) -> Vec<(&'static str, String, String, &mut QuestEffect)> {
    let mut out: Vec<(&'static str, String, String, &mut QuestEffect)> = Vec::new();
    crate::effects::for_each_effect_root_mut(c, &mut |kind, path, key, list| {
        for (i, eff) in list.iter_mut().enumerate() {
            out.push((
                kind.stage(),
                format!("{path}/{i}"),
                format!("{key}.{i}"),
                eff,
            ));
        }
    });
    out
}

/// Visit every effect emission can lower — **top-level and every
/// transitively-nested** one (a `sequence` step, an
/// `on_respawn`/`on_caught`/`on_arrive` bundle) — over all five
/// [`effect_roots`], in the fixed inventory order, invoking
/// `f(stage, path, keybase, effect)`. `stage` names the document the effect lives
/// in (`quests` or `dialogue`) and `path` is its JSON pointer within it, so a
/// diagnostic can point at the real site; `keybase` is its l10n key prefix, derived
/// by the **same** position-keying as [`each_string`]/[`effect_strings_deep`] (so an
/// art narrate's key matches its inventory key). Shared by [`art_narrates`],
/// [`on_screen_narrates`], [`bonfire_option_labels`], [`sound_refs`] and
/// [`play_sound_actor_refs`], so the consumer checks
/// (`DW0326`/`DW0328`/`DW0330`/`DW0331`/`DW0335`) see exactly the strings the
/// inventory demands a translation for.
fn each_effect_ref<'a>(
    c: &'a Campaign,
    f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
) {
    for r in effect_roots(c) {
        effect_deep(r.eff, r.stage, &r.path, &r.key, f);
    }
}

/// Visit `eff` and every transitively-nested effect (depth-first, pre-order),
/// threading the JSON-pointer `path` and l10n `keybase` through each nested list via
/// [`QuestEffect::nested_effect_lists_labeled`] (path segment + key segment + the
/// per-effect index). The key segments match [`effect_strings_deep`] exactly.
fn effect_deep<'a>(
    eff: &'a QuestEffect,
    stage: &'static str,
    path: &str,
    keybase: &str,
    f: &mut dyn FnMut(&'static str, &str, &str, &'a QuestEffect),
) {
    f(stage, path, keybase, eff);
    for (pseg, kseg, list) in eff.nested_effect_lists_labeled() {
        for (j, inner) in list.iter().enumerate() {
            effect_deep(
                inner,
                stage,
                &format!("{path}/{pseg}/{j}"),
                &format!("{keybase}.{kseg}.{j}"),
                f,
            );
        }
    }
}

/// One vanilla sound-event reference (DSL v0.6/v0.4): its stage-doc path and the
/// referenced id, for registry validation (`DW0326`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SoundRef {
    /// The stage document the reference was authored in (`quests` / `dialogue`).
    pub stage: &'static str,
    /// JSON-pointer-ish path within that stage doc.
    pub path: String,
    /// The referenced sound-event id (`minecraft:` prefix optional).
    pub sound: String,
}

/// Every vanilla sound-event id referenced by a quest/trigger effect — a
/// `play-sound`'s `sound` (v0.6) and a `narrate`'s optional `sound` (v0.4) — in a
/// fixed deterministic order, for `DW0326` validation.
pub fn sound_refs(c: &Campaign) -> Vec<SoundRef> {
    let mut out = Vec::new();
    each_effect_ref(c, &mut |stage, path, _key, eff| {
        for (sub, sound) in eff.sound_refs() {
            out.push(SoundRef {
                stage,
                path: format!("{path}/{sub}"),
                sound: sound.to_string(),
            });
        }
    });
    out
}

/// Every `play-sound` effect using the unsupported `at: actor` target, in a fixed
/// order, as `(path, actor-id)` pairs. The actor variant is accepted by the
/// schema and rejected (`DW0335`) by the compiler, which resolves no position for
/// a live actor. `SoundRef::sound` carries the actor id.
pub fn play_sound_actor_refs(c: &Campaign) -> Vec<SoundRef> {
    let mut out = Vec::new();
    each_effect_ref(c, &mut |stage, path, _key, eff| {
        if let Some(actor) = eff.play_sound_actor() {
            out.push(SoundRef {
                stage,
                path: format!("{path}/at/actor"),
                sound: actor.to_string(),
            });
        }
    });
    out
}

/// Replace every inventoried string in `c` with its translation from
/// `translations` (an l10n sidecar's `content`). Keys absent from the map are left
/// as canonical English — but a fully-validated sidecar ([`validate_l10n`]) covers
/// the inventory exactly, so a build only reaches here with complete coverage.
pub fn localize(c: &mut Campaign, translations: &BTreeMap<String, String>) {
    each_string(c, &mut |key, value| {
        if let Some(t) = translations.get(key) {
            *value = t.clone();
        }
    });
}

// ---------------------------------------------------------------------------
// i18n v2 — translation tags and the Minecraft language-code table (spec-0029)
// ---------------------------------------------------------------------------

/// The reserved Unicode **private-use** character that delimits a *translation
/// tag* — the in-band form that carries an inventory key alongside its canonical
/// English from the stage docs to the text component the compiler emits it into.
///
/// A tagged string is `<SIGIL><key><SIGIL><english>` ([`tag`]). It exists only
/// between [`tag_translatables`] and emission: every emitter that lowers an
/// authored string into a **text component** splits it back apart and emits
/// `{"translate": key, "fallback": english}` (spec-0029 §1), and every consumer
/// that wants the human string calls [`plain`].
///
/// The point of an in-band tag is that a site which *fails* to do either leaks the
/// sigil into the built tree, where the compiler's own output scan sees it and
/// fails the build (`DW0185`). That turns "prove every authored string lands in a
/// component" from an audit that rots into an invariant the compiler re-proves on
/// every build, including for emitters not yet written.
///
/// U+E000 is the first code point of the Basic Multilingual Plane's Private Use
/// Area: it has no character assignment, so no authored or translated content can
/// legitimately contain it. [`validate_tr_sigil`] (`DW0183`) reserves the whole
/// block anyway, so the tag can never be forged or shadowed by content.
pub const TR_SIGIL: char = '\u{E000}';

/// The reserved private-use range [`TR_SIGIL`] is drawn from (`U+E000..=U+F8FF`).
/// Reserved wholesale so a near-miss cannot be authored either.
const PUA: std::ops::RangeInclusive<char> = '\u{E000}'..='\u{F8FF}';

/// Build the translation tag for `key` over its canonical English `english`.
pub fn tag(key: &str, english: &str) -> String {
    format!("{TR_SIGIL}{key}{TR_SIGIL}{english}")
}

/// The root segment of the **pack key space** — the key space a delve writes into
/// its resource pack and references from its text components. Distinct from every
/// key space a delve authors in, and from the compiler's own
/// [`chrome::RESERVED_PREFIX`](crate::chrome::RESERVED_PREFIX), so a key's first
/// segment says which space it is in.
pub const PACK_KEY_ROOT: &str = "delve";

/// The prefix every key of one delve's pack key space carries:
/// `delve.<campaign_id>.`.
///
/// # One delve, one vocabulary
///
/// A campaign's own key space (`world.title`, `npc.<n>.name`, …) is
/// **campaign-relative**: it identifies a row inside one campaign's documents, and
/// the l10n sidecar that answers it sits in that campaign's directory. Two
/// campaigns naming the same row therefore write the same key, which is correct
/// where those keys live and catastrophic where they end up.
///
/// Where they end up is a Minecraft client's **merged language table**, and that
/// table is not per-delve. It is the union of every applied resource pack:
/// `tools/creator/playtest-server.sh` installs each delve's pack into the player's
/// `resourcepacks/` directory as `<campaign_id>.zip`, where it stays enabled
/// across servers and worlds, and a server-pushed pack merges on top of whatever
/// is already applied. A `{"translate": …, "fallback": …}` component renders its
/// `fallback` **only when the key is absent from that merged table**, so any delve
/// whose pack is still applied answers for every other delve that asks the same
/// key — one delve's completion toast reading another delve's title, in a language
/// the delve it was playing does not ship.
///
/// So every key that leaves a delve — into a component, into a lang file — is
/// namespaced by the campaign that owns it, and two delves' vocabularies are
/// disjoint sets. The campaign id is the right grain: it is what the pack file is
/// named after, so a rebuilt campaign replaces its own pack rather than joining it.
///
/// Campaign ids are kebab tokens ([`CampaignId::is_valid_syntax`]) and carry no
/// `.`, so the namespace of one id can never be a prefix of another's key.
pub fn pack_namespace(campaign_id: &str) -> String {
    format!("{PACK_KEY_ROOT}.{campaign_id}.")
}

/// One key of `campaign_id`'s [pack key space](pack_namespace): the key as the
/// campaign's own documents and sidecars know it, under that campaign's namespace.
/// The single authority — the tagger, the chrome resolver and the lang-file writer
/// all build their keys here, so what a component references and what the pack
/// defines cannot drift.
pub fn pack_key(campaign_id: &str, key: &str) -> String {
    format!("{}{key}", pack_namespace(campaign_id))
}

/// The directory one delve's baked skin textures occupy inside the pack's shared
/// asset namespace: `<campaign_id>/`.
///
/// # The same defect, in the other space a pack writes into
///
/// A lang key and a texture id are the same kind of thing: a name a delve writes
/// into a space the CLIENT owns. Enabled resource packs merge per path and stay
/// enabled across servers and worlds, so `assets/delvewright/textures/npc/keeper.png`
/// is `keeper`'s face in every delve applying a pack — two delves that both cast a
/// `keeper` render each other's faces, exactly as two delves that both name
/// `world.title` read each other's titles. The grain is the campaign id for the
/// same reason [`pack_namespace`] gives: the pack file is named after it, so a
/// rebuilt campaign replaces its own textures rather than joining them.
///
/// # Why this is not [`pack_key`]'s dotted prefix
///
/// A texture id is not a key, it is a **resource-location path**, and a path's
/// namespace separator is `/`. Vanilla's own assets nest by directory
/// (`textures/entity/villager/…`); a `.` inside a final path segment is legal by
/// the grammar but has no vanilla precedent, and this engine does not invent
/// notation where established practice answers. Same grain, same reasoning, the
/// separator the space uses.
///
/// Campaign ids are kebab tokens ([`CampaignId::is_valid_syntax`]) and so carry no
/// `/`, so one delve's directory can never be a prefix of another's texture id.
pub fn pack_texture_dir(campaign_id: &str) -> String {
    format!("{campaign_id}/")
}

/// One texture id of `campaign_id`'s [texture space](pack_texture_dir): the id as
/// the campaign's own documents know it (`skins/<texture_id>.png`, `DW0190`,
/// `DW0309`), under that campaign's directory. The single authority — the
/// mannequin's `profile.texture` and the pack's archive path are both built from
/// it, so what a summon points at and what the pack ships cannot drift.
pub fn pack_texture_id(campaign_id: &str, texture_id: &str) -> String {
    format!("{}{texture_id}", pack_texture_dir(campaign_id))
}

/// Rewrite every skin declaration in `c` to carry its
/// [pack texture id](pack_texture_id), returning `pack id → authored id` — the
/// map a caller needs to find `skins/<authored id>.png` on disk.
///
/// **The funnel, and the reason this is a rewrite rather than a rule emitters
/// follow.** A body's texture reaches emission exactly one way: an emitter reads
/// `skin.texture_id` off the body it is summoning. Applying the namespace at those
/// emit sites is a rule each of them has to remember — the shape that let an
/// actor's skin be emitted but never baked. Applying it here, at the one walk over
/// every body that declares a skin ([`crate::body_skins_mut`], the mutable
/// mirror of [`crate::body_skin_sites`]), leaves no un-namespaced id in the
/// campaign for a new emit site to find: a summon written tomorrow is namespaced
/// because there is nothing else to read. Same shape as [`tag_translatables`],
/// which is why it sits beside it.
///
/// **The creator's key space does not move.** `texture_id` is what a creator
/// writes in `npcs.json`/`quests.json` and names `skins/<texture_id>.png` after,
/// and `DW0190` (malformed or duplicate id) and `DW0309` (missing PNG) both read it
/// as authored — every one of them runs on the campaign *before* this rewrite, and
/// `validate`/`analyze`, which never emit, never reach it at all.
///
/// Two bodies may name one texture (a character and the puppet that plays it), so
/// the returned map is keyed by pack id and is smaller than the walk.
pub fn namespace_skin_textures(c: &mut Campaign) -> BTreeMap<String, String> {
    let dir = pack_texture_dir(c.world.campaign_id.as_str());
    let mut sources = BTreeMap::new();
    for skin in crate::body_skins_mut(c) {
        let packed = format!("{dir}{}", skin.texture_id);
        sources.insert(
            packed.clone(),
            std::mem::replace(&mut skin.texture_id, packed),
        );
    }
    sources
}

/// Split a translation tag into `(key, english)`. `None` for an untagged string —
/// a compiler-baked literal such as the default boundary message, which has no
/// inventory key and is translated by neither v1 nor v2.
pub fn untag(s: &str) -> Option<(&str, &str)> {
    let rest = s.strip_prefix(TR_SIGIL)?;
    let (key, english) = rest.split_once(TR_SIGIL)?;
    Some((key, english))
}

/// The human string behind `s`: its English source if `s` is a translation tag,
/// otherwise `s` unchanged. The accessor every **non-component** consumer of an
/// authored string uses — the build manifest, the reviewer chronicles, the bot's
/// `critical-path.json`, the generated PackTest sources. Each such site is a named
/// exclusion in `docs/reference/compiler.md`: it is not a text component, so it
/// cannot carry a translate key, and it is not read by a player.
pub fn plain(s: &str) -> &str {
    untag(s).map(|(_, e)| e).unwrap_or(s)
}

/// Whether `s` contains any reserved private-use character — i.e. whether it is,
/// or embeds, a translation tag. The predicate the compiler's build-output scan
/// (`DW0185`) runs over every emitted byte.
pub fn has_tr_sigil(s: &str) -> bool {
    s.chars().any(|c| PUA.contains(&c))
}

/// Rewrite every inventoried player-visible string in `c` into its translation tag
/// ([`tag`]), returning the canonical-English inventory it was derived from.
///
/// Runs on the exact same traversal as [`inventory`] and [`localize`]
/// ([`each_string`]), so the tagged set and the translated set are the same set by
/// construction — the property spec-0029 keeps.
///
/// The campaign handed to the compiler is tagged **once**, before the plan is
/// built; from there the tag is the compiler's only evidence that a string it is
/// about to emit is player-visible and translatable.
///
/// **The tag carries the [pack key](pack_key), not the inventory key.** The
/// returned inventory is the campaign's own key space, unchanged — that is what a
/// sidecar answers and what `DW0180` counts — while what travels to emission, and
/// so into every component and lang file, is `delve.<campaign_id>.<key>`. The two
/// differ exactly where they are read: a sidecar is read inside one campaign's
/// directory, a lang file inside a client's shared language table.
pub fn tag_translatables(c: &mut Campaign) -> BTreeMap<String, String> {
    let ns = pack_namespace(c.world.campaign_id.as_str());
    let mut inv = BTreeMap::new();
    each_string(c, &mut |key, value| {
        inv.insert(key.to_string(), value.clone());
        *value = tag(&format!("{ns}{key}"), value);
    });
    inv
}

/// Reserve the private-use block the translation tag is built from (`DW0183`): no
/// player-visible string — authored English (the whole [`inventory`]) or any
/// declared language's sidecar rendition — may contain a `U+E000..=U+F8FF`
/// character. Language-independent; runs beside [`validate_marker_channel`] on
/// every `validate` / `analyze` / `build`.
pub fn validate_tr_sigil(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
    let mut d = Vec::new();
    let mut flag = |where_: String, key: &str, text: &str| {
        let Some(bad) = text.chars().find(|ch| PUA.contains(ch)) else {
            return;
        };
        d.push(Diagnostic::error(
            codes::TR_SIGIL_RESERVED,
            "l10n",
            where_,
            format!(
                "player-visible string `{key}` contains the reserved private-use character \
                 U+{:04X} — that block is how the compiler carries an l10n key into the text \
                 component this string is emitted as, and it has no rendering in any \
                 Minecraft font. Remove U+{:04X} from the line",
                bad as u32, bad as u32
            ),
        ));
    };
    for (key, text) in inventory(c) {
        flag(format!("#/{key}"), &key, &text);
    }
    for (lang, doc) in sidecars {
        for (key, text) in &doc.content {
            flag(format!("l10n/{lang}.json#/content/{key}"), key, text);
        }
    }
    d
}

/// Every declared-language code this build knows how to write a lang file for, in
/// declaration order, as `(declared code, minecraft code)`. `Err` names the first
/// unmapped code (`DW0184`) — a language is never silently dropped.
pub fn declared_mc_codes(c: &Campaign) -> Result<Vec<(String, &'static str)>, Diagnostic> {
    let mut out = Vec::new();
    for lang in &c.world.content.languages {
        match crate::mclang::mc_lang_code(lang) {
            Some(mc) => out.push((lang.clone(), mc)),
            None => {
                return Err(Diagnostic::error(
                    codes::LANG_CODE_UNMAPPED,
                    "world",
                    format!("/content/languages/{lang}"),
                    format!(
                        "declared language `{lang}` has no Minecraft language-file code — the \
                         resource pack has nowhere to write its \
                         `assets/delvewright/lang/<code>.json`, and the language would ship \
                         invisible. Use a code the pinned 1.21.11 client really loads \
                         (`dsl::mclang::CLIENT_LANGS`, derived from Mojang's own asset index) \
                         — e.g. `zh-cn`, `ja-jp`, `de-de`"
                    ),
                ));
            }
        }
    }
    Ok(out)
}

/// **Translation provenance** (`DW0187` / `DW0188`): is each sidecar row still a
/// translation of the English it renders?
///
/// [`validate_l10n`] proves the sidecar's key SET equals the inventory's. That is
/// silent about whether a row still *corresponds* to its key: rewrite an authored
/// line and its translation is present, applied and wrong, with no key moved. The
/// sidecar's [`L10nDoc::source`] map closes it by recording the English each row
/// was translated from, so the compiler can compare instead of a human auditing.
///
/// Two findings:
///
/// * `DW0187` — a recorded source differs from the key's canonical English (the
///   row is stale), or names a key the sidecar does not translate at all (the
///   provenance itself is stale).
/// * `DW0188` — rows with no recorded provenance, **counted**. Those rows are
///   unguarded, and saying so on every run is what keeps an unadopted sidecar
///   from reading like a checked one. Warning tier: `source` is additive, and
///   this is the one-version deprecation window before it is required.
///
/// The entity display-name rule makes this more than hygiene. A name key belongs
/// to the first site declaring a given text, so renaming ONE body can migrate a
/// key to ANOTHER — the row that goes stale is not the row the author edited, and
/// the missing-key half of the move (`DW0180`) points somewhere else entirely.
pub fn validate_l10n_provenance(
    c: &Campaign,
    sidecars: &BTreeMap<String, L10nDoc>,
) -> Vec<Diagnostic> {
    let mut d = Vec::new();
    if c.world.content.languages.is_empty() {
        return d;
    }
    let inv = inventory(c);
    for lang in &c.world.content.languages {
        let Some(doc) = sidecars.get(lang) else {
            continue; // absent sidecar is DW0180's finding, not this one's.
        };
        for (key, was) in &doc.source {
            match inv.get(key) {
                Some(now) if now == was => {}
                Some(now) => d.push(Diagnostic::error(
                    codes::L10N_STALE,
                    "l10n",
                    format!("l10n/{lang}.json#/source/{key}"),
                    format!(
                        "`{key}` was translated from {was:?} but now reads {now:?} — the \
                         translation in `content` still renders the old line and would ship \
                         attached to the new one. Re-translate `{key}` and update its `source` \
                         (`tools/creator/i18n-translate.py <campaign> --lang {lang}` does both). If a \
                         RENAME surprised you here: an entity display name's key belongs to the \
                         first body declaring that text, so renaming one body can hand its key \
                         to another"
                    ),
                )),
                None => d.push(Diagnostic::error(
                    codes::L10N_STALE,
                    "l10n",
                    format!("l10n/{lang}.json#/source/{key}"),
                    format!(
                        "`source` records `{key}`, which is not in the string inventory — the \
                         provenance is stale even if the translation is gone. Remove `{key}` \
                         from `source` in `l10n/{lang}.json`"
                    ),
                )),
            }
        }
        // Every row `source` does not cover is a row DW0187 cannot see. Report the
        // count: an unadopted sidecar must never look like a checked one.
        let unguarded = doc
            .content
            .keys()
            .filter(|k| !doc.source.contains_key(*k))
            .count();
        if unguarded > 0 {
            let total = doc.content.len();
            d.push(Diagnostic::warning(
                codes::L10N_PROVENANCE_MISSING,
                "l10n",
                format!("l10n/{lang}.json"),
                format!(
                    "{unguarded} of {total} translated rows record no `source`, so nothing can \
                     tell whether they still translate the English they render — an edited line \
                     leaves its translation present, applied and wrong, and no key moves. Run \
                     `tools/creator/i18n-translate.py <campaign> --lang {lang}` to record provenance for \
                     the rows it already has. This warning is the one-version deprecation \
                     window; `source` becomes required after it"
                ),
            ));
        }
    }
    d
}

/// Coverage + envelope validation for every declared language's l10n sidecar
/// (`DW0180` / `DW0181`). Language-independent: it runs on every `validate` /
/// `analyze` / `build`, regardless of `--lang`. Returns no diagnostics for a
/// campaign that declares no languages. `sidecars` is keyed by language code
/// (the `l10n/<code>.json` filename stem).
pub fn validate_l10n(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Vec<Diagnostic> {
    let mut d = Vec::new();
    let declared = &c.world.content.languages;
    if declared.is_empty() {
        return d;
    }
    // A sidecar must carry every key of the inventory (`DW0180`) and no key
    // outside it (`DW0181`).
    let inv = inventory(c);
    let required_keys: BTreeSet<&str> = inv.keys().map(String::as_str).collect();
    let inv_keys: BTreeSet<&str> = required_keys.clone();
    let campaign_id = c.world.campaign_id.as_str();

    for lang in declared {
        if lang == CANONICAL_LANG {
            d.push(Diagnostic::error(
                codes::L10N_MISSING,
                "l10n",
                format!("/world/languages/{lang}"),
                format!(
                    "`{lang}` is the canonical language and must not be declared in \
                     `world.languages` — English is implicit; remove `{lang}` from the list"
                ),
            ));
            continue;
        }
        let Some(doc) = sidecars.get(lang) else {
            d.push(Diagnostic::error(
                codes::L10N_MISSING,
                "l10n",
                format!("l10n/{lang}.json"),
                format!(
                    "declared language `{lang}` has no `l10n/{lang}.json` sidecar — add the \
                     sidecar (a full key→translation map), or remove `{lang}` from \
                     `world.languages`"
                ),
            ));
            continue;
        };
        // Envelope consistency (folded into DW0180 — the sidecar does not correctly
        // cover the declared language).
        if doc.campaign_id.as_str() != campaign_id {
            d.push(Diagnostic::error(
                codes::L10N_MISSING,
                "l10n",
                format!("l10n/{lang}.json"),
                format!(
                    "sidecar `campaign_id` `{}` differs from the campaign's `{campaign_id}` — set \
                     the sidecar's `campaign_id` to `{campaign_id}`",
                    doc.campaign_id
                ),
            ));
        }
        if doc.lang != *lang {
            d.push(Diagnostic::error(
                codes::L10N_MISSING,
                "l10n",
                format!("l10n/{lang}.json"),
                format!(
                    "sidecar `lang` field `{}` differs from filename code `{lang}` — set the \
                     sidecar's `lang` to `{lang}` (it must match the `l10n/{lang}.json` filename)",
                    doc.lang
                ),
            ));
        }
        if doc.dsl_version != DSL_VERSION {
            d.push(Diagnostic::error(
                codes::L10N_MISSING,
                "l10n",
                format!("l10n/{lang}.json"),
                format!(
                    "sidecar declares dsl_version `{}` — set it to `{DSL_VERSION}`, the one \
                     number this engine accepts, matching the stage documents",
                    doc.dsl_version,
                ),
            ));
        }
        // Coverage: missing (DW0180) against what this campaign's version owes,
        // orphan (DW0181) against the whole inventory.
        let side_keys: BTreeSet<&str> = doc.content.keys().map(String::as_str).collect();
        for missing in required_keys.difference(&side_keys) {
            d.push(Diagnostic::error(
                codes::L10N_MISSING,
                "l10n",
                format!("l10n/{lang}.json"),
                format!(
                    "sidecar is missing a translation for inventory key `{missing}` — add \
                     `{missing}` to `l10n/{lang}.json` (coverage must be exact)"
                ),
            ));
        }
        for orphan in side_keys.difference(&inv_keys) {
            d.push(Diagnostic::error(
                codes::L10N_ORPHAN,
                "l10n",
                format!("l10n/{lang}.json#/content/{orphan}"),
                format!(
                    "orphan key `{orphan}` is not in the string inventory — remove it from \
                     `l10n/{lang}.json` (the sidecar must cover exactly the inventory, no extras)"
                ),
            ));
        }
    }
    d
}