Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .backlog/FIX-I18N-DEBT-REPAYMENT/PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
Status: ready

# FIX-I18N-DEBT-REPAYMENT — Repay the pre-existing i18n translation debt

Follow-up to `FIX-I18N-DICT-GUARD` (PR #115, merged `cfb7cd6`), which explicitly deferred this
work: *"A follow-up lot to repay the existing debt must follow immediately"* (ADR 0013).

## Problem Statement

A Mission Maker running CTLD in Korean or Spanish still hits raw English fallback text on some
F10 menu entries today. `FIX-I18N-DICT-GUARD` stopped the bleeding — CI now blocks any *new*
untranslated key — but it deliberately left the dictionaries as they already stood on `develop`:
93 empty entries in `CTLD_i18n_ko.lua` and 78 in `CTLD_i18n_es.lua` (counted at `cfb7cd6`, slightly
above the 91/76 ADR 0013 baseline — a few more landed between the ADR's snapshot and the guard's
merge, which the ADR anticipated and explicitly put in this lot's scope). `CTLD_i18n_fr.lua` has
none — filled by hand in a prior commit, outside the auto-translate path.

## Solution

Run `tools/build/translate_i18n.py` — now fixed by `FIX-I18N-DICT-GUARD` to actually detect an
empty (`""`) entry as a stub, not only one identical to the English text — locally with
`ANTHROPIC_API_KEY` set, against `develop`. It batches every stub per language to Claude Haiku and
writes the translations back in place. Re-run until no stub remains (idempotent: each pass only
resends what's still empty), commit the resulting dictionary files, and open a PR.

No code changes: this lot touches only translation *content* in `src/CTLD_i18n_ko.lua` and
`src/CTLD_i18n_es.lua`.

## User Stories

1. As a Mission Maker playing CTLD in Korean, I want every F10 menu entry translated, so that I
never see raw English fallback text mid-mission.
2. As a Mission Maker playing CTLD in Spanish, I want the same guarantee.
3. As a maintainer, I want the debt repaid using the same tool the project already trusts for this
job (`translate_i18n.py`, `BUILD-DICT-AI-TRANSLATE`), so this lot introduces no new translation
mechanism to review or maintain.
4. As a maintainer, I want the repayment scoped to whatever is actually empty at execution time —
not a number frozen at the ADR 0013 snapshot — so debt that accrued between the ADR and the
guard's merge is repaid in the same pass instead of being left for yet another lot.
5. As a reviewer, I want a PR whose diff is exclusively translation-value changes inside the KO/ES
dictionary files, so review is a scan for corruption (broken escaping, wrong key touched,
malformed Lua) rather than a linguistic audit none of us is qualified to do.
6. As a maintainer, I want confirmation that zero stubs remain in KO and ES once this lot lands, so
"debt repaid" is a verifiable fact, not an approximation.
7. As a future contributor adding a new `ctld.tr()` key, I want this lot to leave `i18n-guard` and
`translate_i18n.py` themselves untouched, so the guard's already-reviewed behavior isn't
revisited as a side effect of a translation-content lot.

## Implementation Decisions

- **Tool**: intended to be `tools/build/translate_i18n.py`, unmodified, run locally with
`ANTHROPIC_API_KEY` set — matching `BUILD-DICT-AI-TRANSLATE`'s deliberate "local-only" decision,
preserved by `FIX-I18N-DICT-GUARD`/ADR 0013. **Deviation**: no `ANTHROPIC_API_KEY` was available
(a separate Anthropic Console API key with its own pay-as-you-go billing, distinct from the
Claude Code subscription used to run this lot) — the maintainer chose to translate directly
rather than acquire one. Translations were produced and written into the dictionary files
without invoking the script or any external API call; `translate_i18n.py` itself is still
untouched and remains the documented mechanism for the next time this debt needs repaying.
- **STALE keys excluded from scope, discovered during execution**: `tools/build/i18n_dict_utils.py`'s
parser (and by extension `translate_i18n.py`'s stub predicate) does not distinguish a live
`ctld.i18n[...] = "..."` line from one commented out with a `-- STALE:` prefix — both match the
same regex. Of the 93 KO / 78 ES stubs counted at merge, 23 (KO) / 8 (ES) keys turned out to be
`-- STALE:` in `CTLD_i18n_en.lua` itself (no longer referenced by any `ctld.tr()`/config-YAML
call in `src/`) — dead debt that translating would not fix any live menu entry for. Excluded;
**70 live entries per language** (same key set for both) were the actual scope. This STALE/live
distinction is a latent gap in the shared parser, not something this content-only lot fixes —
worth a separate tooling follow-up.
- **`__keep_en` extended**: two of the 70 live keys (`JTAC`, a proper noun/sigil, and
`%1 [%2] %3.`, a placeholder-only string with no translatable content) would otherwise stay
flagged as "stub" forever under the tool's own definition (value identical to the EN text).
Added to each dictionary's existing `__keep_en` block instead — the same sanctioned mechanism
already used for `CTLD`, `MLRS`, `%1\nFOB @ %2`, etc.
- **Scope is dynamic, not the ADR 0013 snapshot**: repay every entry that is still an empty stub in
`CTLD_i18n_ko.lua` and `CTLD_i18n_es.lua` at the time the script runs (93 + 78 as last counted at
`cfb7cd6`, expected to shift slightly by execution time), not a hardcoded list of the 91/76 keys
named in ADR 0013. `translate_i18n.py` already scans the full dictionary on every run — targeting
a frozen list would add complexity for no benefit.
- **Idempotent re-run to exhaustion**: run the script, inspect what (if anything) is still empty,
re-run — repeat until both `CTLD_i18n_ko.lua` and `CTLD_i18n_es.lua` have zero empty entries
outside their `__keep_en` block. The script already only resends what's still a stub, so repeated
runs cost nothing extra for already-translated keys.
- **No new tooling, no changes to `translate_i18n.py`, `i18n_dict_utils.py`, `check_i18n_diff.py`,
or the `i18n-guard` CI job.** This lot is a data-only repayment on top of already-reviewed
machinery.
- **`CTLD_i18n_fr.lua` untouched**: already at zero empty entries, out of this lot's scope by
construction (nothing for `translate_i18n.py` to pick up there).
- **Review model**: no dedicated KO/ES linguistic review (no native speaker on the team; Claude
Haiku via `translate_i18n.py` is the already-accepted mechanism for this exact case). Verification
is mechanical: confirm the stub count reaches zero, and a normal PR diff review for structural
correctness (each hunk touches exactly one dictionary value, no key added/removed, no broken Lua
string escaping).

## Testing Decisions

- No new or changed logic ships in this lot — `translate_i18n.py` and its unit tests
(`tools/build/test_translate_i18n.py`, `tools/build/test_i18n_dict_utils.py`) already cover the
stub-detection predicate this lot relies on, and stay green untouched (`pytest tools/build/`).
- Repayment completeness is verified mechanically, not via a new automated test: after each
`translate_i18n.py` run, confirm zero remaining empty entries in `CTLD_i18n_ko.lua` and
`CTLD_i18n_es.lua` (outside `__keep_en`) by inspection — e.g. reusing the existing dict parser
(`tools/build/i18n_dict_utils.py`'s `parse_dict`/`parse_keep_en`) in a throwaway check, or a
direct grep for `= ""` cross-checked against each file's `__keep_en` block. This is a one-time
verification for this lot's PR, not new permanent test coverage.
- `generate_i18n_dicts.ps1`'s dry-run (already run by the existing `i18n-guard` CI job on the PR)
confirms no `MISSING` key was introduced by this lot — expected to report clean since no key is
added or removed, only stub values filled in.
- No `src/` *logic* changes, so `busted tests/ci/` and `luacheck` are unaffected — run as routine
CI, no new failures expected.

## Out of Scope

- **Any change to `translate_i18n.py`, `i18n_dict_utils.py`, `check_i18n_diff.py`, or the
`i18n-guard` CI job.** Already delivered and reviewed in `FIX-I18N-DICT-GUARD`.
- **Linguistic review of the generated KO/ES text.** No native-speaker review process exists for
this project; Claude Haiku via the existing tool is the accepted translation mechanism.
- **`CTLD_i18n_fr.lua`.** Already fully translated, nothing for this lot to do there.
- **Adding or removing a supported language.** fr/es/ko stays the complete non-EN set.
- **New automated test coverage.** No new logic is introduced; existing `tools/build/` tests
already cover the mechanism this lot exercises.

## Further Notes

- Full rationale for why this debt was deferred rather than blocked immediately: ADR 0013
(`dev/adr/0013-ci-i18n-dict-guard.md`).
- Prior art: `BUILD-DICT-AI-TRANSLATE` (PR #60) introduced `translate_i18n.py` and the
local-only/`ANTHROPIC_API_KEY` decision this lot relies on without modification.
- `FIX-I18N-DICT-SYNC` (PR #57) is the precedent for a prior i18n debt-repayment pass (60+ FR menu
labels filled by hand after a path bug), confirming this project has repaid dictionary debt as
its own lot before.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Status: ready

# 01 — Repay KO + ES i18n stub debt

## Parent

`.backlog/FIX-I18N-DEBT-REPAYMENT/PRD.md`

## What to build

Originally scoped to run `tools/build/translate_i18n.py` locally with `ANTHROPIC_API_KEY` set. No
key was available (separate Anthropic Console billing, not the Claude Code subscription used to
run this lot) — translations were produced and written directly into
`src/CTLD_i18n_ko.lua` / `src/CTLD_i18n_es.lua` instead, without invoking the script or any
external API. `translate_i18n.py` itself is untouched.

Of the 93 KO / 78 ES stubs counted at `FIX-I18N-DICT-GUARD`'s merge (`cfb7cd6`), 23 / 8 turned out
to be `-- STALE:` in `CTLD_i18n_en.lua` (no longer referenced by any `ctld.tr()`/config-YAML call
in `src/` — the shared parser doesn't distinguish a live entry from a commented-out one, so they
still counted as raw stubs). Translating dead keys fixes no live menu entry, so they were excluded;
**70 live entries per language** (identical key set for both) were the actual scope.

Two of those 70 keys (`JTAC`, `%1 [%2] %3.`) have no translatable content and would stay flagged
as stubs forever under the tool's own "value == EN text" definition — added to each dictionary's
`__keep_en` block instead, the existing mechanism for exactly this case.

`CTLD_i18n_fr.lua` is already fully translated and was left untouched.

## Acceptance criteria

- [x] All 70 live (non-`STALE`) empty entries in `src/CTLD_i18n_ko.lua` translated.
- [x] All 70 live (non-`STALE`) empty entries in `src/CTLD_i18n_es.lua` translated.
- [x] `src/CTLD_i18n_ko.lua` has zero live entries equal to `""` outside its `__keep_en` block.
- [x] `src/CTLD_i18n_es.lua` has zero live entries equal to `""` outside its `__keep_en` block.
- [x] `src/CTLD_i18n_fr.lua` is unchanged (already fully translated).
- [x] No key added, removed, or renamed in any of the four dictionaries — only existing empty
values filled in, plus two `__keep_en` additions (`JTAC`, `%1 [%2] %3.`) in KO and ES.
- [x] `pytest tools/build/` still passes (no logic touched by this ticket) — 17/17 green.
- [x] `generate_i18n_dicts.ps1` dry-run reports no `MISSING` key (nothing added/removed).
- [x] `CHANGELOG.md` `[Unreleased]` updated noting the debt repayment.
- [ ] PR opened against `develop`; diff contains only translation-value changes in
`CTLD_i18n_ko.lua` / `CTLD_i18n_es.lua` (plus `CHANGELOG.md` and the backlog index line).

## Blocked by

None - can start immediately (`FIX-I18N-DICT-GUARD` already merged on `develop`)
1 change: 1 addition & 0 deletions .backlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ authored **per lot, when the lot is started** (not in batch).
| `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` |
| `FEAT-CUSTOM-BEACON-SOUNDS` | merged (PR #112) | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` |
| `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `<ctld version>-<commit hash>`. | `feature/dev-build-channel` |
| [`FIX-I18N-DEBT-REPAYMENT`](FIX-I18N-DEBT-REPAYMENT/PRD.md) | merged (PR #116) | Follow-up to `FIX-I18N-DICT-GUARD` (PR #115): repays the pre-existing i18n translation debt the guard deliberately left alone (93 empty `CTLD_i18n_ko.lua` entries + 78 empty `CTLD_i18n_es.lua` entries, counted at merge `cfb7cd6`, above the 91/76 ADR 0013 baseline — a few more landed before the guard shipped). No `ANTHROPIC_API_KEY` available, so the 70 live entries per language (the rest were `-- STALE:` dead keys, excluded) were translated directly rather than via `translate_i18n.py`; `JTAC` and `%1 [%2] %3.` added to `__keep_en`. No code changes — translation content only, no new tooling. | `fix/i18n-debt-repayment` |
| [`FIX-I18N-DICT-GUARD`](FIX-I18N-DICT-GUARD/PRD.md) | merged (PR #115) | Reported by **FullGas**: some F10 menu entries stay untranslated in Korean. Root cause: `translate_i18n.py`'s stub detection never matches the `""` empty-stub convention `generate_i18n_dicts.ps1 -Apply` actually writes, so freshly-added keys are never picked up for translation even with `ANTHROPIC_API_KEY` set — and the only existing guard (`.githooks/pre-push`, opt-in, `MISSING`-only) doesn't check translation content. New CI job `i18n-guard`, diff-scoped like `changelog-guard`: blocks unconditionally on `MISSING`, blocks by default (bypassable via `skip-i18n` label) on a newly-introduced empty non-EN entry. Fixes `translate_i18n.py`'s stub detection in the same lot. Pre-existing debt (91 KO + 76 ES empty entries) explicitly out of scope — a follow-up repayment lot must follow immediately. See **ADR 0013**. | `fix/i18n-dict-guard` |
| [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` |
| [`FIX-MENU-DOUBLE-MULTICREW`](FIX-MENU-DOUBLE-MULTICREW/PRD.md) | merged (PR #106) | F10 menu duplication on multi-crew aircraft (CH-47 pilot + copilot); menu loss when one crew member leaves a shared group. | — |
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ Versioning follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed — pre-existing KO/ES i18n translation debt repaid (FIX-I18N-DEBT-REPAYMENT)

- **`CTLD_i18n_ko.lua` and `CTLD_i18n_es.lua` had 93 and 78 empty entries** respectively on
`develop` — F10 menu entries silently falling back to English for Korean/Spanish Mission Makers.
Follow-up repayment lot promised by `FIX-I18N-DICT-GUARD` (ADR 0013).
- **70 live entries translated per language** (the same set for both — every key still referenced
by a current `ctld.tr()`/config-YAML call). The remaining 23/8 empty entries counted at
`FIX-I18N-DICT-GUARD`'s merge were `-- STALE:` in `CTLD_i18n_en.lua` (no longer referenced
anywhere in `src/`) — repaying dead keys would help no one, so they were left alone.
- **`JTAC` and `%1 [%2] %3.` added to each dictionary's `__keep_en` block**: a sigil and a
placeholder-only string respectively, neither translatable — the existing sanctioned mechanism
for "intentionally kept as English", already used for `CTLD`, `MLRS`, etc.
- No tooling changed: `translate_i18n.py`, `i18n_dict_utils.py`, `check_i18n_diff.py` and the
`i18n-guard` CI job are untouched. Translation content only.

### Fixed — CI now catches untranslated i18n menu entries before merge (FIX-I18N-DICT-GUARD)

- **`translate_i18n.py`'s stub detection was broken since it shipped**: it only recognised a
Expand Down
Loading