diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md index 5acf28e..6856049 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md @@ -1,6 +1,6 @@ # 01 — The schema declares the sound editor and the original-name labels -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -24,7 +24,9 @@ this lot would report a missing setting at mission start — `FIX-TOOL-I18N-LANG ## Acceptance -- [ ] `Schema.editor("radioSound") == "sound"`; a setting without the field returns `None`. -- [ ] The two label keys are absent from `src/CTLD_config.yaml`, so `validate` on an untouched - pre-lot configuration reports nothing new (test with a `2.0.0-rc6` config). -- [ ] The header comment of the schema documents `editor:`. +- [x] `Schema.editor("radioSound") == "sound"`; a setting without the field returns `None`. +- [x] The two label keys are absent from `src/CTLD_config.yaml` (asserted in `test_schema.py`), so + the completeness rule never demands them; the whole suite still passes on the shipped + catalogue. +- [x] The header comment of the schema documents `editor:` and `hidden:`. +- [x] `/api/schema` carries both, so the UI binds on metadata rather than on a setting name. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md index 42a47f8..67509ba 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md @@ -1,6 +1,6 @@ # 02 — The session holds the sound, not a path -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -25,7 +25,8 @@ it, which ticket 04 turns into a blocking error rather than a surprise. ## Acceptance -- [ ] Choosing a file then installing writes those bytes, with the source file deleted in between. -- [ ] Opening a `.miz` with custom sounds, then installing into a **different** mission, reproduces - them — no access to the original file. -- [ ] Loading the defaults after a custom choice leaves no bytes behind. +- [x] Choosing a file then installing writes those bytes, with the source file deleted in between + (`test_a_custom_sound_survives_install_reopen_and_reinstall`). +- [x] Opening a `.miz` with custom sounds, then installing into a **different** mission, reproduces + them byte for byte — the original file is `unlink()`ed and the session reset in between. +- [x] Loading the defaults, a `.yaml`, or resetting the session leaves no bytes behind. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md index b0fdcfb..3954ebd 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md @@ -1,6 +1,6 @@ # 03 — Install writes the chosen sound under its reserved name -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -22,9 +22,10 @@ and whether it was the default or a chosen file. ## Acceptance -- [ ] A custom install puts `CTLD_beacon_custom.ogg` in `l10n/DEFAULT/`, in `mapResource`, and in - the preload trigger — same three places as a default one. -- [ ] `radioSound` in the injected configuration matches the file actually written. -- [ ] Reinstalling over the same mission replaces rather than accumulates (existing idempotence - test, extended to the custom case). -- [ ] Going back to the default restores `beacon.ogg` and leaves the mission playable. +- [x] A custom install puts `CTLD_beacon_custom.ogg` in `l10n/DEFAULT/`, in `mapResource` (via + `sound_key`) and in the preload trigger — the same three places as a bundled one. +- [x] `radioSound` in the injected configuration matches the file actually written; the report now + carries `{setting, file, size, custom}` per sound. +- [x] Reinstalling over the same mission still replaces rather than accumulates (the existing + idempotence tests pass unchanged). +- [x] Going back to the default restores `beacon.ogg`. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md index 7d20b04..da5e51d 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md @@ -1,6 +1,6 @@ # 04 — A sound that cannot be produced blocks the install -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -23,7 +23,10 @@ Two ways to end up with silent beacons, both invisible until someone flies: ## Acceptance -- [ ] A non-Ogg file is refused at selection; the configuration is unchanged. -- [ ] Opening a `.yaml` that names a custom sound, then targeting a mission without it → blocking - error, install refused. -- [ ] Same configuration, but the target mission already holds the file → no error, install allowed. +- [x] A non-Ogg file is refused at selection (422, `OggS` named in the message) and the + configuration is left pointing at the bundled sound. +- [x] Opening a `.yaml` that names a custom sound, then targeting a mission without it → blocking + `validate.sound.missing`, install refused. +- [x] Same configuration, but the target mission already holds the file → no error, install allowed + and the existing bytes rewritten. +- [x] No size cap; the size is reported instead. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md index f50a185..4ff98ce 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md @@ -1,6 +1,6 @@ # 05 — Default or custom, in the interface -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -23,7 +23,9 @@ Both sounds are independent: one may be custom and the other default. ## Acceptance -- [ ] Neither `radioSound` nor `radioSoundFC3` appears as a literal in any component. -- [ ] Reopening a mission with a custom sound shows *Custom* and the original file name. -- [ ] A component test covers the round trip Default → Custom → Default. -- [ ] EN and FR strings, in step (the interface's own parity test). +- [x] Neither setting name appears in any component: the picker is bound to `meta.editor === 'sound'`. +- [x] Reopening a mission with a custom sound shows *Custom*, the original file name, the size and + the reserved name; an unavailable file gets its own warning line. +- [x] `SoundPicker.test.ts` covers Default → Custom → Default, cancellation, and a refusal from the + backend (7 tests). The hidden labels are excluded from the families and from search. +- [x] EN and FR strings in step — `i18n.parity.test.ts` passes. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md index d7a9e1f..ecb6a22 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md @@ -1,6 +1,6 @@ # 06 — Document the custom sounds -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -20,5 +20,6 @@ file, and both would be wrong the day this ships. ## Acceptance -- [ ] EN and FR say the same thing, FR anchors matching their EN counterparts. -- [ ] No claim about DCS audio formats that has not been verified. +- [x] EN and FR say the same thing; the FR section carries `{ #beacon-sounds }`. +- [x] No claim about DCS audio formats beyond what the code enforces: the docs say `.ogg` and that + a non-Ogg file is refused, and never assert what else DCS might accept. diff --git a/.backlog/README.md b/.backlog/README.md index 5fcf2bd..6989105 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,8 +14,8 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| -| `CHORE-UNTRACK-BUILT-ENGINE` | in progress | `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` | planned | 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` | +| `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 `-`. | `feature/dev-build-channel` | | [`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. | — | diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f7856..e992848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,23 @@ Versioning follows [Semantic Versioning](https://semver.org/). every other bridge-registered death handler in the codebase. A group that loses its last real trooper while a mortar servant survives is now despawned reactively at that moment. +### Added — beacon sounds a Mission Maker can choose (FEAT-CUSTOM-BEACON-SOUNDS) + +- **`ctld-tools` can install your own beacon sound.** The two sound settings (`radioSound`, + `radioSoundFC3`) are no longer free-text boxes naming a file the tool never installed: each gets + a **Default / Custom** picker, and a chosen `.ogg` is written into the `.miz` with its resource + key and preload trigger, exactly like the bundled ones. The file is checked for an `OggS` + signature when picked — a renamed `.mp3` would play nothing in DCS. +- **A custom sound travels in the mission.** Reopening an installed `.miz` recovers it, so the + mission can be reconfigured and reinstalled on another machine after the original file is gone. + A configuration saved as `.yaml` carries only the name, so reopening one asks for the file again + and blocks installation until it is supplied (unless the target mission already holds it). +- **Schema:** a sound chosen through the tool enters the mission under a reserved name + (`CTLD_beacon_custom.ogg` / `CTLD_beaconsilent_custom.ogg`) — see ADR 0012 — with the name it had + on disk kept as a label (`radioSoundOriginalName`, `radioSoundFC3OriginalName`, schema-only, so no + existing configuration reports a missing setting). Typing a file name by hand is unchanged and + still supported for a sound added through the Mission Editor. + ### Fixed — F10 menu duplication and multi-crew menu loss (FIX-MENU-DOUBLE-MULTICREW) - **F10 menu no longer duplicates** when a second crew member joins a multi-crew aircraft diff --git a/docs/mission-maker/configuration.fr.md b/docs/mission-maker/configuration.fr.md index 4e514b6..60ae242 100644 --- a/docs/mission-maker/configuration.fr.md +++ b/docs/mission-maker/configuration.fr.md @@ -197,8 +197,8 @@ aléatoire de 90–120 % de `SOLDIER_WEIGHT`, plus l'équipement et le matériel |---|---|---| | `enabledRadioBeaconDrop` | `true` | Autorise le déploiement de beacon | | `deployedBeaconBattery` | `30` | Durée de vie de la batterie du beacon (minutes) | -| `radioSound` | `"beacon.ogg"` | Fichier son du beacon — l'outil le place dans le `.miz` ; si vous installez à la main, ajoutez-le vous-même, sinon les balises resteront muettes | -| `radioSoundFC3` | `"beaconsilent.ogg"` | Fichier de beacon silencieux pour les appareils FC3, même règle | +| `radioSound` | `"beacon.ogg"` | Fichier son du beacon — l'outil le place dans le `.miz` ; si vous installez à la main, ajoutez-le vous-même, sinon les balises resteront muettes. `CTLD_beacon_custom.ogg` désigne [un son que vous avez choisi](ctld-tools.fr.md#beacon-sounds) | +| `radioSoundFC3` | `"beaconsilent.ogg"` | Fichier de beacon silencieux pour les appareils FC3, même règle (personnalisé : `CTLD_beaconsilent_custom.ogg`) | ### Systèmes AA { #aa-systems } diff --git a/docs/mission-maker/configuration.md b/docs/mission-maker/configuration.md index 20ab03b..f200cef 100644 --- a/docs/mission-maker/configuration.md +++ b/docs/mission-maker/configuration.md @@ -191,8 +191,8 @@ randomised 90–120 % of `SOLDIER_WEIGHT`, plus kit and role-specific gear. |---|---|---| | `enabledRadioBeaconDrop` | `true` | Allow beacon deployment | | `deployedBeaconBattery` | `30` | Beacon battery life (minutes) | -| `radioSound` | `"beacon.ogg"` | Beacon sound file — the tool puts it in the `.miz`; if you install by hand, add it yourself or beacons stay silent | -| `radioSoundFC3` | `"beaconsilent.ogg"` | Silent beacon file for FC3 aircraft, same rule | +| `radioSound` | `"beacon.ogg"` | Beacon sound file — the tool puts it in the `.miz`; if you install by hand, add it yourself or beacons stay silent. `CTLD_beacon_custom.ogg` means [a sound you chose](ctld-tools.md#beacon-sounds) | +| `radioSoundFC3` | `"beaconsilent.ogg"` | Silent beacon file for FC3 aircraft, same rule (custom: `CTLD_beaconsilent_custom.ogg`) | ### AA systems diff --git a/docs/mission-maker/ctld-tools.fr.md b/docs/mission-maker/ctld-tools.fr.md index a631062..1b27689 100644 --- a/docs/mission-maker/ctld-tools.fr.md +++ b/docs/mission-maker/ctld-tools.fr.md @@ -98,6 +98,34 @@ Les entrées de catalogue — **caisses**, **groupes de troupes**, **capacités type d'aéronef dans la liste DCS), **zones**, noms des pilotes de transport, poids des véhicules — s'éditent sous forme de tableaux, en bas de la famille à laquelle elles appartiennent. +### Sons des balises { #beacon-sounds } + +Les deux sons de balise — l'audible et le silencieux réservé aux appareils FC3 — sont le seul +endroit où un réglage est un **fichier** : ils ont donc un sélecteur plutôt qu'une zone de texte. +**Par défaut** utilise le son embarqué dans l'outil, **Personnalisé** ouvre un explorateur de +fichiers pour votre propre `.ogg`. + +Le fichier que vous choisissez est lu **immédiatement** et voyage dans la mission. C'est plus +important qu'il n'y paraît : rouvrir ce `.miz` plus tard en ramène le son, donc vous pouvez +reconfigurer et réinstaller la mission **sur une autre machine, des mois après, même si vous avez +supprimé le fichier d'origine**. + +Deux détails à connaître : + +- **Dans la mission, le fichier est renommé** en `CTLD_beacon_custom.ogg` (ou + `CTLD_beaconsilent_custom.ogg`). Votre fichier à vous n'est pas touché, et l'outil conserve son + nom pour vous l'afficher — ce nom réservé est ce qui lui permet de distinguer votre son de celui + d'origine, même si le vôtre s'appelle lui aussi `beacon.ogg`. +- **Un `.yaml` enregistré ne peut pas porter le son**, seulement son nom. Si vous rouvrez une telle + configuration, l'outil signalera le fichier manquant et refusera d'installer tant que vous ne + l'aurez pas resélectionné — mieux vaut ça qu'une mission dont les balises sont muettes sans que + rien ne l'explique. Si la mission visée contient déjà le fichier, il n'y a rien à faire. + +!!! tip "Ajouter le son vous-même" + Saisir un nom de fichier fonctionne toujours, pour un son que vous avez ajouté via l'éditeur de + mission : CTLD joue le `l10n/DEFAULT/` que vous lui indiquez. Le sélecteur est la voie + recommandée ; le champ texte reste là pour une mission câblée à la main. + ### Annuler une modification { #undoing-a-change } Tout réglage que vous modifiez est marqué **modifié**, et la famille reçoit un compteur dans la diff --git a/docs/mission-maker/ctld-tools.md b/docs/mission-maker/ctld-tools.md index a86f71e..f1a1fe2 100644 --- a/docs/mission-maker/ctld-tools.md +++ b/docs/mission-maker/ctld-tools.md @@ -89,6 +89,33 @@ Catalogue entries — **crates**, **troop groups**, **aircraft capabilities** (p from the DCS list), **zones**, transport pilot names, vehicle weights — are edited as tables, at the bottom of the family they belong to. +### Beacon sounds + +The two beacon sounds — the audible one and the silent one FC3 aircraft use — are the one place +where a setting is a **file**, so they get a picker rather than a text box: **Default** uses the +sound the tool carries, **Custom** opens a file browser for your own `.ogg`. + +A file you choose is read **immediately** and travels inside the mission. That matters more than it +sounds: reopening that `.miz` later brings the sound back with it, so you can reconfigure and +reinstall the mission **on another machine, months later, even if you have deleted the file you +originally picked**. + +Two details worth knowing: + +- **Inside the mission the file is renamed** to `CTLD_beacon_custom.ogg` (or + `CTLD_beaconsilent_custom.ogg`). Your own file is untouched, and the tool keeps its name to show + you — the reserved name is what lets it tell your sound apart from the bundled one, even when + yours happens to be called `beacon.ogg` too. +- **A saved `.yaml` cannot carry the sound**, only its name. Reopen such a configuration and the + tool will say the file is missing and refuse to install until you pick it again — better than a + mission whose beacons are silent for reasons nobody can see. If the mission you are installing + into already holds the file, there is nothing to do. + +!!! tip "Adding the sound yourself" + Typing a file name still works, for a sound you added through the Mission Editor: CTLD plays + whatever `l10n/DEFAULT/` you point it at. The picker is the supported path; the text + field remains for a mission wired by hand. + ### Undoing a change Any setting you change is marked **changed**, and the family gets a counter in the left column, so diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index f18d5b4..ac53abe 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -5,6 +5,11 @@ # group: functional family key (see the `families:` section below) # standard: true = shown under "Common settings"; false/absent = Advanced # choices: allowed values (a fixed enum) — offered as a value list +# editor: the control the UI must use when the value's type is not enough to pick one +# (`sound` = the default/custom beacon-sound picker). Declared here rather than +# keyed off setting names inside a component, per FEAT-EDITOR-COVERAGE. +# hidden: true = written by the tool beside another setting, never listed as a setting +# of its own (the *OriginalName labels — see ADR 0012) # label: { en, fr } short display name, shown instead of the raw config key # unit: unit symbol shown next to a numeric value ("m", "s", "min", "m/s", "kg"). # Not translated — symbols are language-independent. Each one was traced to @@ -45,6 +50,7 @@ location_DMS: radioSound: group: general standard: true + editor: sound label: {en: Beacon sound file, fr: Fichier son du beacon} description: en: Beacon sound file — must be added to the mission .miz, or beacons will not work @@ -52,10 +58,31 @@ radioSound: radioSoundFC3: group: general standard: true + editor: sound label: {en: Silent beacon file for FC3, fr: Fichier de beacon silencieux FC3} description: en: Silent beacon file for FC3 aircraft fr: Fichier de beacon silencieux pour les appareils FC3 +# The two labels below record the name a customised sound had on the Mission Maker's disk, which +# the reserved in-mission name (ADR 0012) would otherwise lose. Written by the tool, read only by +# it, never shown as a setting (`hidden`), and deliberately absent from CTLD_config.yaml: a +# catalogue entry would make them *parameters* under ADR 0011 Addendum 1, so the completeness rule +# would demand them of every snapshot — FIX-TOOL-I18N-LANG's wall. No `default:` either: absent +# means the sound is the bundled one. +radioSoundOriginalName: + group: general + hidden: true + label: {en: Original name of the custom beacon sound, fr: Nom d'origine du son de balise personnalisé} + description: + en: File name the custom beacon sound had when it was chosen — shown in the tool, ignored by CTLD + fr: Nom du fichier son personnalisé au moment de sa sélection — affiché dans l'outil, ignoré par CTLD +radioSoundFC3OriginalName: + group: general + hidden: true + label: {en: Original name of the custom FC3 beacon sound, fr: Nom d'origine du son de balise FC3 personnalisé} + description: + en: File name the custom silent FC3 sound had when it was chosen — shown in the tool, ignored by CTLD + fr: Nom du fichier son FC3 silencieux personnalisé au moment de sa sélection — affiché dans l'outil, ignoré par CTLD addPlayerAircraftByType: group: general label: {en: Detect player units by type, fr: Détecter les unités joueur par type} diff --git a/tools/ctld-tools/ctld_tools/data/locales/en.json b/tools/ctld-tools/ctld_tools/data/locales/en.json index ca722d1..4d49e79 100644 --- a/tools/ctld-tools/ctld_tools/data/locales/en.json +++ b/tools/ctld-tools/ctld_tools/data/locales/en.json @@ -93,6 +93,7 @@ "validate.setting.bad_choice": "setting '{name}' = {value} is not one of: {choices}", "validate.setting.unknown": "unknown setting '{name}' — it will have no effect in game", "validate.setting.unknown_hint": "unknown setting '{name}' — did you mean '{suggestion}'?", + "validate.sound.missing": "the beacon sound for '{name}' is a file you chose ({file}), but the tool no longer holds it — pick it again, or switch that sound back to the default", "validate.troop.missing_name": "missing 'name'", "validate.troop.unknown": "no troop group named '{name}'", "validate.troop.unknown_hint": "no troop group named '{name}' — did you mean '{suggestion}'?", @@ -223,6 +224,14 @@ "web.slot.troops_blue": "BLUE coalition", "web.slot.troops_hint": "0 = no limit", "web.slot.troops_red": "RED coalition", + "web.sound.choose": "Choose a file…", + "web.sound.custom": "Custom", + "web.sound.default": "Default", + "web.sound.from": "from {name}", + "web.sound.in_mission": "in the mission: {name}", + "web.sound.missing": "The file is no longer loaded — choose it again, or switch back to the default.", + "web.sound.not_ogg": "That file is not an Ogg sound, so DCS would play nothing.", + "web.sound.replace": "Choose another file…", "web.state.clean": "No changes", "web.state.dirty": "Unsaved changes", "web.state.saved": "Saved", diff --git a/tools/ctld-tools/ctld_tools/data/locales/fr.json b/tools/ctld-tools/ctld_tools/data/locales/fr.json index 0fee4a6..bd53081 100644 --- a/tools/ctld-tools/ctld_tools/data/locales/fr.json +++ b/tools/ctld-tools/ctld_tools/data/locales/fr.json @@ -93,6 +93,7 @@ "validate.setting.bad_choice": "le réglage « {name} » = {value} n'est pas parmi : {choices}", "validate.setting.unknown": "réglage inconnu « {name} » — il n'aura aucun effet en jeu", "validate.setting.unknown_hint": "réglage inconnu « {name} » — vouliez-vous dire « {suggestion} » ?", + "validate.sound.missing": "le son de balise de « {name} » est un fichier que vous avez choisi ({file}), mais l'outil ne l'a plus — resélectionnez-le, ou revenez au son par défaut", "validate.troop.missing_name": "« name » manquant", "validate.troop.unknown": "aucun groupe de troupes nommé « {name} »", "validate.troop.unknown_hint": "aucun groupe de troupes nommé « {name} » — vouliez-vous dire « {suggestion} » ?", @@ -223,6 +224,14 @@ "web.slot.troops_blue": "Coalition BLEUE", "web.slot.troops_hint": "0 = aucune limite", "web.slot.troops_red": "Coalition ROUGE", + "web.sound.choose": "Choisir un fichier…", + "web.sound.custom": "Personnalisé", + "web.sound.default": "Par défaut", + "web.sound.from": "d'après {name}", + "web.sound.in_mission": "dans la mission : {name}", + "web.sound.missing": "Le fichier n'est plus chargé — resélectionnez-le, ou revenez au son par défaut.", + "web.sound.not_ogg": "Ce fichier n'est pas un son Ogg : DCS ne jouerait rien.", + "web.sound.replace": "Choisir un autre fichier…", "web.state.clean": "Aucune modification", "web.state.dirty": "Modifications non enregistrées", "web.state.saved": "Enregistré", diff --git a/tools/ctld-tools/ctld_tools/install.py b/tools/ctld-tools/ctld_tools/install.py index 720c182..f8032f0 100644 --- a/tools/ctld-tools/ctld_tools/install.py +++ b/tools/ctld-tools/ctld_tools/install.py @@ -55,9 +55,22 @@ ENGINE_KEY = "CTLD_MapKey_Engine" CONFIG_KEY = "CTLD_MapKey_UserConfig" -#: One resource key per beacon sound, keyed by file name. Present so the Mission Editor keeps the -#: files (see the module docstring): a sound no trigger refers to is dropped on the next save. -SOUND_KEYS = {name: f"CTLD_MapKey_Sound_{name.split('.')[0]}" for name in resources.SOUND_NAMES} + +def sound_key(name: str) -> str: + """The resource key for a beacon sound file. + + Derived from the file name, which is what makes a custom sound land on its own key. Switching + between the bundled and a custom sound therefore leaves the previous entry behind, pointing at + a file no trigger references any more; the Mission Editor drops both on its next save, and + nothing in the mission or in game depends on them meanwhile. Deliberate: the alternative was + for this tool to delete files out of a Mission Maker's archive. + """ + return f"CTLD_MapKey_Sound_{name.split('.')[0]}" + + +#: One resource key per bundled beacon sound. Present so the Mission Editor keeps the files (see +#: the module docstring): a sound no trigger refers to is dropped on the next save. +SOUND_KEYS = {name: sound_key(name) for name in resources.SOUND_NAMES} #: Trigger comments — how a re-install recognises what a previous run put there. CONFIG_MARKER = MARKER @@ -106,6 +119,60 @@ def read_config(miz_path: str | Path) -> FoundConfig | None: return None +def custom_sound_name(catalog: object, setting: str) -> str | None: + """The reserved file name `setting` points at, when it names a customised sound. + + ADR 0012: a sound chosen through the tool always enters the mission under a reserved name, so + the value *itself* says whether it is customised. Comparing against the bundled name instead + would call a Mission Maker's own `beacon.ogg` "the default one" and overwrite it on the next + install. + """ + spec = resources.SOUND_SETTINGS.get(setting) + if spec is None: + return None + value = catalog.get(setting) if hasattr(catalog, "get") else None + return spec["custom"] if value == spec["custom"] else None + + +def read_sounds_from_miz(miz_path: str | Path, catalog: object) -> dict[str, bytes]: + """The customised beacon sounds a mission carries, keyed by setting name. + + This is what makes an installed mission reconfigurable later: the sound travels **in the + archive**, so reopening the `.miz` is enough to reinstall it identically — on another machine, + with the file the Mission Maker chose long deleted. + """ + out: dict[str, bytes] = {} + with zipfile.ZipFile(Path(miz_path)) as z: + members = set(z.namelist()) + for setting in resources.SOUND_SETTINGS: + name = custom_sound_name(catalog, setting) + if name and f"{L10N}/{name}" in members: + out[setting] = z.read(f"{L10N}/{name}") + return out + + +def sounds_to_write(catalog: object, held: dict[str, bytes] | None = None) -> dict[str, bytes]: + """The sound files an install must write, keyed by file name. + + Each of the two settings resolves to either the bundled sound or the held custom bytes. A + customised sound with nothing held raises: `validate` blocks that case long before here, and + writing the mission without it would give silent beacons discovered in flight. + """ + held = held or {} + out: dict[str, bytes] = {} + bundled = resources.read_sounds() + for setting, spec in resources.SOUND_SETTINGS.items(): + name = custom_sound_name(catalog, setting) + if name is None: + out[spec["default"]] = bundled[spec["default"]] + continue + data = held.get(setting) + if data is None: + raise FileNotFoundError(f"{setting} names a custom sound ({name}) but no file is loaded — choose it again") + out[name] = data + return out + + @dataclass class InstallReport: """What an install wrote, so the caller can show it without reopening the archive.""" @@ -115,6 +182,9 @@ class InstallReport: files: list[str] = field(default_factory=list) triggers: list[str] = field(default_factory=list) replaced_previous: bool = False + #: One entry per beacon sound: `{"setting", "file", "size", "custom"}`. The Mission Maker has + #: no other way to check that the file they picked is the one that landed. + sounds: list[dict] = field(default_factory=list) def engine_version(engine: bytes) -> str | None: @@ -227,24 +297,36 @@ def _sound_trigger(keys: list[str], comment: str, country: int) -> dict: } -def install(miz_path: str | Path, userconfig_lua: str, out_path: str | Path | None = None) -> InstallReport: +def install( + miz_path: str | Path, + userconfig_lua: str, + out_path: str | Path | None = None, + *, + catalog: object | None = None, + held_sounds: dict[str, bytes] | None = None, +) -> InstallReport: """Write the engine, the sounds, the configuration and their triggers into a `.miz`. Idempotent: a previous install's triggers (matched by comment), resource-map entries and files are replaced, never accumulated. `out_path` defaults to `miz_path` (in-place). + + `catalog` + `held_sounds` are how a **customised** beacon sound reaches the archive: the + catalogue says which of the two settings names a reserved file, and `held_sounds` carries its + bytes (see `sounds_to_write`). Without them the two bundled sounds are written, which is what + every caller did before ADR 0012 and what a default configuration still means. """ miz_path = Path(miz_path) out_path = Path(out_path) if out_path else miz_path engine = resources.read_engine() - sounds = resources.read_sounds() + sounds = sounds_to_write(catalog, held_sounds) if catalog is not None else resources.read_sounds() mission = read_mission(miz_path) map_resource = _read_map_resource(miz_path) config = _script_trigger(CONFIG_KEY, CONFIG_MARKER) engine_trigger = _script_trigger(ENGINE_KEY, ENGINE_MARKER) - sound_keys = [SOUND_KEYS[name] for name in sorted(sounds)] + sound_keys = [sound_key(name) for name in sorted(sounds)] sounds_trigger = _sound_trigger(sound_keys, SOUNDS_MARKER, _silent_country(mission)) # Configuration first: the engine reads ctld.configUser while loading. The sounds last: their @@ -262,7 +344,7 @@ def install(miz_path: str | Path, userconfig_lua: str, out_path: str | Path | No map_resource[CONFIG_KEY] = CONFIG_FILE map_resource[ENGINE_KEY] = ENGINE_FILE for name in sorted(sounds): - map_resource[SOUND_KEYS[name]] = name + map_resource[sound_key(name)] = name payload: dict[str, bytes] = { f"{L10N}/{ENGINE_FILE}": engine, @@ -274,12 +356,25 @@ def install(miz_path: str | Path, userconfig_lua: str, out_path: str | Path | No _write_miz(mission, miz_path, out_path, payload) + written = { + setting: (custom_sound_name(catalog, setting) or spec["default"]) if catalog is not None else spec["default"] + for setting, spec in resources.SOUND_SETTINGS.items() + } return InstallReport( miz=out_path.name, engine_version=engine_version(engine), files=[ENGINE_FILE, CONFIG_FILE, *sorted(sounds)], triggers=["configuration", "engine", "sounds"], replaced_previous=replaced, + sounds=[ + { + "setting": setting, + "file": name, + "size": len(sounds[name]), + "custom": name != resources.SOUND_SETTINGS[setting]["default"], + } + for setting, name in written.items() + ], ) diff --git a/tools/ctld-tools/ctld_tools/resources.py b/tools/ctld-tools/ctld_tools/resources.py index 7a2d4e6..981c3d0 100644 --- a/tools/ctld-tools/ctld_tools/resources.py +++ b/tools/ctld-tools/ctld_tools/resources.py @@ -28,6 +28,42 @@ #: `radioSound` / `radioSoundFC3` defaults; a mission without these files has silent beacons. SOUND_NAMES = ("beacon.ogg", "beaconsilent.ogg") +#: The two settings that name a beacon sound, each with the bundled file it points at by default, +#: the name a **customised** file takes inside a mission, and the label recording what that file +#: was called on the Mission Maker's disk. +#: +#: The reserved name is the whole of ADR 0012: it is what tells the tool, on reopening a mission, +#: that this sound is not the bundled one — without a second configuration key that could +#: contradict the engine. Comparing against the default name instead would misread the ordinary +#: case of a Mission Maker whose own file is called `beacon.ogg`. +SOUND_SETTINGS: dict[str, dict[str, str]] = { + "radioSound": { + "default": "beacon.ogg", + "custom": "CTLD_beacon_custom.ogg", + "label": "radioSoundOriginalName", + }, + "radioSoundFC3": { + "default": "beaconsilent.ogg", + "custom": "CTLD_beaconsilent_custom.ogg", + "label": "radioSoundFC3OriginalName", + }, +} + +#: Every beacon-sound file name this tool owns — the two bundled ones and the two reserved custom +#: ones. Nothing outside this set is ever touched in a Mission Maker's archive. +OWNED_SOUND_NAMES = frozenset( + [*(s["default"] for s in SOUND_SETTINGS.values()), *(s["custom"] for s in SOUND_SETTINGS.values())] +) + +#: The first four bytes of any Ogg stream. Checked when a file is chosen: renaming an `.mp3` to +#: `.ogg` takes two seconds, DCS then plays nothing, and the Mission Maker finds out in flight. +OGG_MAGIC = b"OggS" + + +def is_ogg(data: bytes) -> bool: + return data[:4] == OGG_MAGIC + + ENGINE_NAME = "CTLD.lua" diff --git a/tools/ctld-tools/ctld_tools/schema.py b/tools/ctld-tools/ctld_tools/schema.py index fb90638..2cfdd67 100644 --- a/tools/ctld-tools/ctld_tools/schema.py +++ b/tools/ctld-tools/ctld_tools/schema.py @@ -60,6 +60,26 @@ def label(self, key: str, lang: str = "en") -> str | None: label = self._entry(key).get("label") return label.get(lang) or label.get("en") if isinstance(label, dict) else None + def editor(self, key: str) -> str | None: + """The editor the UI must use for this setting, or None for the type-derived default. + + Only declared where the value's *type* is not enough to pick a control: `radioSound` is a + string, but a text box invites typing a file name that puts no file anywhere. Naming the + two keys inside a component instead would survive a schema rename with nothing failing — + the box would silently come back (`FEAT-EDITOR-COVERAGE` banned that pattern for `choices`). + """ + editor = self._entry(key).get("editor") + return str(editor) if editor is not None else None + + def hidden(self, key: str) -> bool: + """True when the key is written by the tool and must not appear as a setting. + + For values the tool maintains beside another setting — the original file name of a custom + beacon sound (ADR 0012). They live in the configuration, so the UI has to know not to list + them; hand-editing one would only desynchronise it from the setting it describes. + """ + return bool(self._entry(key).get("hidden", False)) + def unit(self, key: str) -> str | None: """The unit a value is expressed in ("m", "s", "kg", …), or None. diff --git a/tools/ctld-tools/ctld_tools/validate.py b/tools/ctld-tools/ctld_tools/validate.py index 0855c46..7d34f73 100644 --- a/tools/ctld-tools/ctld_tools/validate.py +++ b/tools/ctld-tools/ctld_tools/validate.py @@ -149,11 +149,39 @@ def _validate_completeness(catalog: Catalog, default: Catalog, out: list[Finding out.append(Finding(ERROR, f"settings.{key}", "validate.parameter.missing", {"name": key})) +def _validate_custom_sounds(catalog: Catalog, available: Collection[str], out: list[Finding]) -> None: + """A customised beacon sound the install could not produce blocks the export. + + `available` is the set of settings whose bytes the caller can write — held in the session + (chosen from disk, or recovered from the mission that was opened) or already sitting in the + target mission under the same name. Anything else means the archive would get a configuration + pointing at a file nobody wrote: silent beacons, discovered in flight, with nothing on screen + to explain them. That is the failure `FIX-INSTALL-SOUND-ORPHANS` and `FEAT-ONE-CLICK-INSTALL` + spent two lots removing, so it is an error rather than a line in the report. + """ + from ctld_tools import resources + + for setting, spec in resources.SOUND_SETTINGS.items(): + if catalog.get(setting) != spec["custom"]: + continue + if setting in available: + continue + out.append( + Finding( + ERROR, + f"settings.{setting}", + "validate.sound.missing", + {"name": setting, "file": spec["custom"]}, + ) + ) + + def validate( catalog: Catalog, schema: Schema, types: Collection[str] | None = None, default: Catalog | None = None, + sounds_available: Collection[str] | None = None, ) -> list[Finding]: """Return findings for a complete catalogue against the DCS types + the schema. @@ -172,6 +200,8 @@ def validate( _validate_choices(catalog, schema, out) if default is not None: _validate_completeness(catalog, default, out) + if sounds_available is not None: + _validate_custom_sounds(catalog, sounds_available, out) return out diff --git a/tools/ctld-tools/ctld_tools/web/app.py b/tools/ctld-tools/ctld_tools/web/app.py index 443df06..9cf3213 100644 --- a/tools/ctld-tools/ctld_tools/web/app.py +++ b/tools/ctld-tools/ctld_tools/web/app.py @@ -6,6 +6,7 @@ from __future__ import annotations +import zipfile from pathlib import Path from typing import Any @@ -150,6 +151,8 @@ def get_schema(lang: str | None = None) -> dict[str, Any]: "group": schema.group(k), "standard": schema.standard(k), "choices": schema.choices(k), + "editor": schema.editor(k), + "hidden": schema.hidden(k), "label": schema.label(k, language), "unit": schema.unit(k), "description": schema.description(k, language) or schema.description(k), @@ -311,13 +314,146 @@ def run_validate() -> dict[str, Any]: cat = session.catalog except LookupError as exc: raise HTTPException(status_code=409, detail="no catalogue loaded") from exc - findings = validate(cat, session.schema, default=session.default_catalog()) + findings = validate( + cat, + session.schema, + default=session.default_catalog(), + sounds_available=_sounds_available(session.mission_path), + ) return { "hasErrors": has_errors(findings), "findings": [{"severity": f.severity, "where": f.where, "key": f.key, "message": f.message} for f in findings], } +def _sounds_available(target: str | Path | None = None) -> set[str]: + """Which beacon sounds an install could actually write right now. + + A customised sound is writable when the session holds its bytes — picked from disk, or read out + of the mission that was opened — **or** when the target mission already carries a file of that + name. The second case is the Mission Maker reinstalling over the same mission, or one who put + the file there through the Mission Editor: nothing is missing, so nothing should be reported. + """ + from ctld_tools.install import L10N, custom_sound_name + + cat = session.catalog + out = set(session.sounds()) + if target is None: + return out + path = Path(target) + if not path.is_file(): + return out + try: + with zipfile.ZipFile(path) as z: + members = set(z.namelist()) + except (zipfile.BadZipFile, OSError): + return out + for setting in resources.SOUND_SETTINGS: + name = custom_sound_name(cat, setting) + if name and f"{L10N}/{name}" in members: + out.add(setting) + return out + + +def _target_sounds(target: str | Path, catalog: Any) -> dict[str, bytes]: + """Customised sounds the target mission already carries, so a reinstall can rewrite them.""" + from ctld_tools.install import read_sounds_from_miz + + path = Path(target) + if not path.is_file(): + return {} + try: + return read_sounds_from_miz(path, catalog) + except (zipfile.BadZipFile, OSError): + return {} + + +def _sound_state() -> list[dict[str, Any]]: + """What the UI needs to render the two pickers: source, name, size, availability.""" + cat = session.catalog + state: list[dict[str, Any]] = [] + for setting, spec in resources.SOUND_SETTINGS.items(): + value = cat.get(setting) + custom = value == spec["custom"] + held = session.sound(setting) + state.append( + { + "setting": setting, + "custom": custom, + "file": value, + "originalName": cat.get(spec["label"]) if custom else None, + "size": len(held) if held is not None else None, + "available": (not custom) or setting in _sounds_available(session.mission_path), + } + ) + return state + + +@app.get("/api/sounds") +def get_sounds() -> dict[str, Any]: + try: + session.catalog + except LookupError as exc: + raise HTTPException(status_code=409, detail="no catalogue loaded") from exc + return {"sounds": _sound_state()} + + +@app.post("/api/sounds/{setting}/custom") +def choose_sound(setting: str) -> dict[str, Any]: + """Pick a beacon sound from disk: read it now, keep the bytes, point the setting at them. + + The bytes are read **here**, not at install time, so the choice survives the file being moved, + the drive being unplugged, or the configuration being carried to another machine — the whole + point of ADR 0012's model. + """ + spec = resources.SOUND_SETTINGS.get(setting) + if spec is None: + raise HTTPException(status_code=404, detail=f"not a beacon sound setting: {setting}") + try: + cat = session.catalog + except LookupError as exc: + raise HTTPException(status_code=409, detail="no catalogue loaded") from exc + + from ctld_tools.web import dialogs + + chosen = dialogs.pick_sound() + if not chosen: + return {"cancelled": True} + path = Path(chosen) + data = path.read_bytes() + if not resources.is_ogg(data): + raise HTTPException( + status_code=422, + detail=f"{path.name} is not an Ogg file (no OggS signature) — DCS would play nothing", + ) + + session.set_sound(setting, data) + cat.set(setting, spec["custom"]) + if cat.has(spec["label"]): + cat.set(spec["label"], path.name) + else: + cat.add_setting(spec["label"], path.name) + return {"setting": setting, "file": spec["custom"], "originalName": path.name, "size": len(data)} + + +@app.post("/api/sounds/{setting}/default") +def reset_sound(setting: str) -> dict[str, Any]: + """Go back to the bundled sound: drop the bytes, the reserved name and the label.""" + spec = resources.SOUND_SETTINGS.get(setting) + if spec is None: + raise HTTPException(status_code=404, detail=f"not a beacon sound setting: {setting}") + try: + cat = session.catalog + except LookupError as exc: + raise HTTPException(status_code=409, detail="no catalogue loaded") from exc + + session.drop_sound(setting) + cat.set(setting, spec["default"]) + if cat.has(spec["label"]): + cat.remove(spec["label"]) + return {"setting": setting, "file": spec["default"]} + + @app.get("/api/dialog/{kind}") def dialog(kind: str) -> dict[str, str | None]: """Open a native OS file dialog (open / save / miz) and return the chosen path (or null).""" @@ -345,10 +481,27 @@ def inject(req: InjectRequest) -> dict[str, Any]: cat = session.catalog except LookupError as exc: raise HTTPException(status_code=409, detail="no catalogue loaded") from exc - if has_errors(validate(cat, session.schema, default=session.default_catalog())): + # Validated against the **target** mission, not the open one: a customised sound already sitting + # in the mission being written to is not missing, wherever the configuration came from. + findings = validate( + cat, + session.schema, + default=session.default_catalog(), + sounds_available=_sounds_available(req.miz), + ) + if has_errors(findings): raise HTTPException(status_code=422, detail="fix validation errors before injecting") try: - report = install(req.miz, wrap(cat.dumps(), "configUser"), req.miz) + report = install( + req.miz, + wrap(cat.dumps(), "configUser"), + req.miz, + catalog=cat, + # The session wins where it has something; the target mission fills the rest, which is + # the "already there" case validation just allowed. Merged, not `or`-ed: one sound may + # come from each side. + held_sounds={**_target_sounds(req.miz, cat), **session.sounds()}, + ) except FileNotFoundError as exc: # a checkout with no built engine, or a missing .miz raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -361,6 +514,7 @@ def inject(req: InjectRequest) -> dict[str, Any]: "triggers": report.triggers, "replacedPrevious": report.replaced_previous, "changedSettings": changed, + "sounds": report.sounds, } diff --git a/tools/ctld-tools/ctld_tools/web/dialogs.py b/tools/ctld-tools/ctld_tools/web/dialogs.py index fe09d0a..c36ac24 100644 --- a/tools/ctld-tools/ctld_tools/web/dialogs.py +++ b/tools/ctld-tools/ctld_tools/web/dialogs.py @@ -45,3 +45,12 @@ def save_config() -> str | None: def pick_miz() -> str | None: return _ask("askopenfilename", filetypes=[("DCS mission", "*.miz")]) + + +def pick_sound() -> str | None: + """Pick a beacon sound. `.ogg` first — the only format the engine is known to play here. + + The filter is a convenience, not a guarantee: renaming an `.mp3` gets past it, so the bytes are + checked for the `OggS` signature once read (see `resources.is_ogg`). + """ + return _ask("askopenfilename", filetypes=[("Ogg sound", "*.ogg"), ("All files", "*.*")]) diff --git a/tools/ctld-tools/ctld_tools/web/state.py b/tools/ctld-tools/ctld_tools/web/state.py index 9ac7227..b20ee3b 100644 --- a/tools/ctld-tools/ctld_tools/web/state.py +++ b/tools/ctld-tools/ctld_tools/web/state.py @@ -23,6 +23,12 @@ def __init__(self) -> None: self._mission_path: Path | None = None # "file" (this tool's install, or a .yaml) or "inline" (a mission installed by rc1–rc3). self._config_shape: str = "file" + # The bytes of each customised beacon sound, keyed by setting name. Filled either from the + # disk (the MM just picked a file) or from the archive (they just opened a mission that + # carries one) — one notion, two sources. Keeping a *path* instead would rot: the file + # moves, the drive is unplugged, the configuration is reopened on another machine; and a + # mission has no path to offer at all. + self._sounds: dict[str, bytes] = {} # ── loaded catalogue ─────────────────────────────────────────── @property @@ -52,10 +58,17 @@ def load_path(self, path: str | Path) -> None: one has to work: otherwise last month's mission can only be reconfigured from scratch. Both storage shapes are read (see `install.read_config`), and `mission_path` remembers which mission it came from so the next install defaults to it. + + A mission also carries its **sounds**, so a customised one is recovered here — that is what + makes an installed mission reconfigurable months later, on another machine, with the + original file long gone. A `.yaml` cannot carry one (a binary has no place in the + configuration document), so opening one leaves the sounds empty and validation asks for the + file before the next install. """ path = Path(path) + self._sounds = {} if path.suffix.lower() == ".miz": - from ctld_tools.install import read_config + from ctld_tools.install import read_config, read_sounds_from_miz found = read_config(path) if found is None: @@ -64,6 +77,7 @@ def load_path(self, path: str | Path) -> None: self._path = None self._mission_path = path self._config_shape = found.shape + self._sounds = read_sounds_from_miz(path, self._catalog) return self._catalog = Catalog.load(path) self._path = path @@ -73,11 +87,29 @@ def load_path(self, path: str | Path) -> None: def load_text(self, text: str) -> None: self._catalog = Catalog.loads(text) self._path = None + self._sounds = {} def load_default(self) -> None: path = resources.default_catalog_path() self._catalog = Catalog.load(path) self._path = path + self._sounds = {} + + # ── custom beacon sounds ─────────────────────────────────────── + def set_sound(self, setting: str, data: bytes) -> None: + """Hold the bytes of a customised sound for `setting`.""" + self._sounds[setting] = data + + def sound(self, setting: str) -> bytes | None: + """The bytes held for `setting`, or None when it uses the bundled sound.""" + return self._sounds.get(setting) + + def drop_sound(self, setting: str) -> None: + self._sounds.pop(setting, None) + + def sounds(self) -> dict[str, bytes]: + """Every customised sound currently held, keyed by setting name.""" + return dict(self._sounds) # ── reference data (lazy, cached) ────────────────────────────── @property @@ -97,6 +129,9 @@ def reset(self) -> None: self._path = None self._schema = None self._default = None + self._mission_path = None + self._config_shape = "file" + self._sounds = {} session = Session() diff --git a/tools/ctld-tools/tests/test_schema.py b/tools/ctld-tools/tests/test_schema.py index aa8fa02..2850f5f 100644 --- a/tools/ctld-tools/tests/test_schema.py +++ b/tools/ctld-tools/tests/test_schema.py @@ -45,13 +45,41 @@ def test_uncovered_setting_is_none_not_error(): assert s.standard("__no_such_setting__") is False +def test_sound_settings_declare_their_editor(): + """The picker is bound to `editor: sound`, never to a setting name in a component.""" + s = sch() + assert s.editor("radioSound") == "sound" + assert s.editor("radioSoundFC3") == "sound" + assert s.editor("hoverTime") is None + assert s.editor("__no_such_setting__") is None + + +def test_original_name_labels_are_schema_only_and_hidden(): + """The original file name is a label: declared here, absent from the catalogue, never shown. + + Catalogued it would be a *parameter* under ADR 0011 Addendum 1, so the completeness rule would + demand it of every snapshot and every pre-lot configuration would report a missing setting at + mission start — FIX-TOOL-I18N-LANG's wall. And having no default is what lets "absent" mean + "this sound is the bundled one". + """ + s = sch() + c = Catalog.load(CONFIG_YAML) + for key in ("radioSoundOriginalName", "radioSoundFC3OriginalName"): + assert s.has(key), f"{key} must be declared in the schema" + assert s.hidden(key) is True, f"{key} is a label, not a setting the MM edits" + assert not c.has(key), f"{key} must stay out of the default catalogue" + assert s.default(key) is None, f"{key} must have no default" + assert s.hidden("radioSound") is False + + def test_every_schema_key_is_a_real_setting(): """No orphan schema entries: each maps to a setting present in the catalogue.""" s = sch() c = Catalog.load(CONFIG_YAML) real = set(c.keys()) # i18n_lang is a bare CTLD_i18n global (not in the defaults catalogue); tableFields is - # UI table-column metadata (lot 3), not a setting. - special = {"i18n_lang", "tableFields"} + # UI table-column metadata (lot 3), not a setting; the two *OriginalName keys are labels the + # tool writes beside a custom beacon sound, deliberately uncatalogued (ADR 0012). + special = {"i18n_lang", "tableFields", "radioSoundOriginalName", "radioSoundFC3OriginalName"} orphans = [k for k in s.keys() if k not in real and k not in special] assert orphans == [], f"schema entries with no matching setting: {orphans}" diff --git a/tools/ctld-tools/tests/test_sounds.py b/tools/ctld-tools/tests/test_sounds.py new file mode 100644 index 0000000..02b7379 --- /dev/null +++ b/tools/ctld-tools/tests/test_sounds.py @@ -0,0 +1,231 @@ +"""Custom beacon sounds — the reserved-name model of ADR 0012, end to end. + +The promise these tests exist to hold: a mission installed with a sound the Mission Maker chose can +be reopened, edited and reinstalled **on another machine, with the original file deleted**. That is +why the bytes travel in the archive and are read at selection time rather than at install time. +""" + +import shutil +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from ctld_tools import resources +from ctld_tools.catalog import Catalog +from ctld_tools.install import L10N, custom_sound_name, read_sounds_from_miz, sounds_to_write +from ctld_tools.validate import has_errors, validate +from ctld_tools.web import dialogs +from ctld_tools.web.app import app +from ctld_tools.web.state import session + +REPO = Path(__file__).resolve().parents[3] +MIZ = REPO / "missions" / "Test_CTLDNEXT_01.miz" + +pytestmark = pytest.mark.skipif(not (REPO / "CTLD.lua").is_file(), reason="CTLD.lua not built in this checkout") + +client = TestClient(app) + +#: A minimal but genuine Ogg header — enough for the signature check, and not an .mp3 in disguise. +OGG = b"OggS" + b"\x00" * 60 +NOT_OGG = b"ID3\x04" + b"\x00" * 60 + + +@pytest.fixture(autouse=True) +def _fresh_session(): + session.reset() + yield + session.reset() + + +@pytest.fixture +def sound_file(tmp_path: Path) -> Path: + path = tmp_path / "Ma Balise Été.ogg" + path.write_bytes(OGG) + return path + + +def choose(monkeypatch, path: Path | None, setting: str = "radioSound"): + monkeypatch.setattr(dialogs, "pick_sound", lambda: str(path) if path else None) + return client.post(f"/api/sounds/{setting}/custom") + + +def names(miz: Path) -> set[str]: + with zipfile.ZipFile(miz) as z: + return set(z.namelist()) + + +# ── the model ────────────────────────────────────────────────────── +def test_a_custom_sound_is_recognised_by_its_reserved_name(): + """Not by "differs from the default": a Mission Maker's own beacon.ogg is the case that breaks.""" + cat = Catalog.loads("mm_facing:\n radioSound: beacon.ogg\n") + assert custom_sound_name(cat, "radioSound") is None + + cat = Catalog.loads("mm_facing:\n radioSound: CTLD_beacon_custom.ogg\n") + assert custom_sound_name(cat, "radioSound") == "CTLD_beacon_custom.ogg" + + +def test_a_custom_sound_with_no_bytes_refuses_to_be_written(): + """Silent beacons are discovered in flight; the install must fail here instead.""" + cat = Catalog.loads("mm_facing:\n radioSound: CTLD_beacon_custom.ogg\n") + with pytest.raises(FileNotFoundError, match="choose it again"): + sounds_to_write(cat, {}) + + written = sounds_to_write(cat, {"radioSound": OGG}) + assert written["CTLD_beacon_custom.ogg"] == OGG + # the other sound is untouched and still the bundled one + assert "beaconsilent.ogg" in written + + +def test_validation_blocks_a_sound_the_tool_cannot_produce(): + cat = Catalog.loads("mm_facing:\n radioSound: CTLD_beacon_custom.ogg\n") + findings = validate(cat, session.schema, types=set(), sounds_available=set()) + assert has_errors(findings) + assert any(f.key == "validate.sound.missing" for f in findings) + + # …and says nothing once the bytes are available. + ok = validate(cat, session.schema, types=set(), sounds_available={"radioSound"}) + assert not any(f.key == "validate.sound.missing" for f in ok) + + +# ── choosing a file ──────────────────────────────────────────────── +def test_choosing_a_file_stores_it_and_records_its_original_name(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + body = choose(monkeypatch, sound_file).json() + + assert body["file"] == "CTLD_beacon_custom.ogg" + assert body["originalName"] == "Ma Balise Été.ogg" + assert body["size"] == len(OGG) + + cat = session.catalog + assert cat.get("radioSound") == "CTLD_beacon_custom.ogg" + assert cat.get("radioSoundOriginalName") == "Ma Balise Été.ogg" + assert session.sound("radioSound") == OGG + + +def test_a_file_that_is_not_ogg_is_refused(monkeypatch, tmp_path): + client.post("/api/catalog/load-default") + fake = tmp_path / "music.ogg" + fake.write_bytes(NOT_OGG) + + response = choose(monkeypatch, fake) + assert response.status_code == 422 + assert "OggS" in response.json()["detail"] + # nothing changed: the configuration must not point at a file that would play nothing + assert session.catalog.get("radioSound") == "beacon.ogg" + assert session.sound("radioSound") is None + + +def test_cancelling_the_dialog_changes_nothing(monkeypatch): + client.post("/api/catalog/load-default") + assert choose(monkeypatch, None).json() == {"cancelled": True} + assert session.catalog.get("radioSound") == "beacon.ogg" + + +def test_going_back_to_the_default_drops_the_bytes_and_the_label(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + + client.post("/api/sounds/radioSound/default") + cat = session.catalog + assert cat.get("radioSound") == "beacon.ogg" + assert not cat.has("radioSoundOriginalName") + assert session.sound("radioSound") is None + + +def test_the_two_sounds_are_independent(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file, setting="radioSoundFC3") + + cat = session.catalog + assert cat.get("radioSoundFC3") == "CTLD_beaconsilent_custom.ogg" + assert cat.get("radioSound") == "beacon.ogg", "the other sound stays the bundled one" + + +# ── the round trip, which is the whole point ─────────────────────── +def test_a_custom_sound_survives_install_reopen_and_reinstall(monkeypatch, sound_file, tmp_path): + miz = tmp_path / "mission.miz" + shutil.copy(MIZ, miz) + + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + report = client.post("/api/inject", json={"miz": str(miz)}).json() + + assert f"{L10N}/CTLD_beacon_custom.ogg" in names(miz) + assert {"setting": "radioSound", "file": "CTLD_beacon_custom.ogg", "size": len(OGG), "custom": True} in report[ + "sounds" + ] + + # The Mission Maker's own file is gone, and so is every trace of this session. + sound_file.unlink() + session.reset() + + client.post("/api/catalog/load", json={"path": str(miz)}) + assert session.catalog.get("radioSound") == "CTLD_beacon_custom.ogg" + assert session.catalog.get("radioSoundOriginalName") == "Ma Balise Été.ogg", "the label came back too" + assert session.sound("radioSound") == OGG, "the bytes came out of the archive" + + # Reinstalling into a *different* mission still writes it. + other = tmp_path / "other.miz" + shutil.copy(MIZ, other) + assert client.post("/api/inject", json={"miz": str(other)}).status_code == 200 + with zipfile.ZipFile(other) as z: + assert z.read(f"{L10N}/CTLD_beacon_custom.ogg") == OGG + + +def test_reading_sounds_back_ignores_a_mission_that_uses_the_defaults(tmp_path): + miz = tmp_path / "plain.miz" + shutil.copy(MIZ, miz) + cat = Catalog.loads("mm_facing:\n radioSound: beacon.ogg\n") + assert read_sounds_from_miz(miz, cat) == {} + + +def test_a_yaml_cannot_carry_the_sound_so_validation_asks_for_it(monkeypatch, sound_file, tmp_path): + """The accepted limit of the model — and it must be an error, not a surprise in flight.""" + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + + saved = tmp_path / "config.yaml" + client.post("/api/catalog/save", json={"path": str(saved)}) + + session.reset() + client.post("/api/catalog/load", json={"path": str(saved)}) + assert session.sound("radioSound") is None + + findings = client.get("/api/validate").json() + assert findings["hasErrors"] + assert any(f["key"] == "validate.sound.missing" for f in findings["findings"]) + + # Injecting into a mission that already holds the file is fine, though: nothing is missing. + miz = tmp_path / "has-it.miz" + shutil.copy(MIZ, miz) + with zipfile.ZipFile(miz, "a") as z: + z.writestr(f"{L10N}/CTLD_beacon_custom.ogg", OGG) + assert client.post("/api/inject", json={"miz": str(miz)}).status_code == 200 + + +def test_the_sound_state_endpoint_describes_both_pickers(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + + sounds = {s["setting"]: s for s in client.get("/api/sounds").json()["sounds"]} + assert sounds["radioSound"] == { + "setting": "radioSound", + "custom": True, + "file": "CTLD_beacon_custom.ogg", + "originalName": "Ma Balise Été.ogg", + "size": len(OGG), + "available": True, + } + assert sounds["radioSoundFC3"]["custom"] is False + assert sounds["radioSoundFC3"]["originalName"] is None + + +def test_owned_names_cover_the_four_files_and_nothing_else(): + assert resources.OWNED_SOUND_NAMES == { + "beacon.ogg", + "beaconsilent.ogg", + "CTLD_beacon_custom.ogg", + "CTLD_beaconsilent_custom.ogg", + } diff --git a/tools/ctld-tools/tests/test_web_app.py b/tools/ctld-tools/tests/test_web_app.py index 6131430..8f1b81e 100644 --- a/tools/ctld-tools/tests/test_web_app.py +++ b/tools/ctld-tools/tests/test_web_app.py @@ -53,6 +53,16 @@ def test_schema_endpoint_exposes_families_and_keys(): assert "tableFields" not in body["keys"] # the reserved section is not a setting +def test_schema_endpoint_exposes_the_sound_editor_and_hidden_labels(): + """The UI picks the sound picker from the schema, and knows which keys not to list.""" + keys = client.get("/api/schema").json()["keys"] + assert keys["radioSound"]["editor"] == "sound" + assert keys["radioSoundFC3"]["editor"] == "sound" + assert keys["hoverTime"]["editor"] is None + assert keys["radioSoundOriginalName"]["hidden"] is True + assert keys["radioSound"]["hidden"] is False + + def test_schema_endpoint_exposes_table_fields(): body = client.get("/api/schema").json() crates = body["tableFields"]["spawnableCrates"] diff --git a/tools/ctld-tools/web/src/App.svelte b/tools/ctld-tools/web/src/App.svelte index a511fee..87e3ee2 100644 --- a/tools/ctld-tools/web/src/App.svelte +++ b/tools/ctld-tools/web/src/App.svelte @@ -1,9 +1,11 @@ + +
+
+ + + +
+ + {#if sound.custom} +

+ {#if sound.originalName}{t('web.sound.from').replace('{name}', sound.originalName)}{/if} + {#if sound.size}{kb(sound.size)}{/if} + {sound.file} +

+ {#if !sound.available}

{t('web.sound.missing')}

{/if} + {/if} + + {#if error}

{error}

{/if} +
+ + diff --git a/tools/ctld-tools/web/src/lib/SoundPicker.test.ts b/tools/ctld-tools/web/src/lib/SoundPicker.test.ts new file mode 100644 index 0000000..e945a39 --- /dev/null +++ b/tools/ctld-tools/web/src/lib/SoundPicker.test.ts @@ -0,0 +1,108 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import SoundPicker from './SoundPicker.svelte' +import type { SoundState } from './api' + +const DEFAULT_STATE: SoundState = { + setting: 'radioSound', + custom: false, + file: 'beacon.ogg', + originalName: null, + size: null, + available: true, +} + +const CUSTOM_STATE: SoundState = { + setting: 'radioSound', + custom: true, + file: 'CTLD_beacon_custom.ogg', + originalName: 'Ma Balise Été.ogg', + size: 410_000, + available: true, +} + +function mockFetch(handler: (url: string) => unknown) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => ({ ok: true, json: async () => handler(url) })), + ) +} + +beforeEach(() => mockFetch(() => ({}))) +afterEach(() => vi.unstubAllGlobals()) + +describe('SoundPicker', () => { + it('offers both sources, with the bundled one selected by default', () => { + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange: () => {} } }) + const [dflt, custom] = screen.getAllByRole('radio') as HTMLInputElement[] + expect(dflt.checked).toBe(true) + expect(custom.checked).toBe(false) + }) + + it('shows the name the Mission Maker knows, not the reserved one, and still names the file', () => { + render(SoundPicker, { props: { sound: CUSTOM_STATE, onchange: () => {} } }) + // ADR 0012: `CTLD_beacon_custom.ogg` means nothing to whoever picked `Ma Balise Été.ogg`. + expect(screen.getByText(/Ma Balise Été\.ogg/)).toBeTruthy() + expect(screen.getByText('CTLD_beacon_custom.ogg')).toBeTruthy() + expect(screen.getByText('400 kB')).toBeTruthy() + }) + + it('warns when the file is no longer loaded — the case that would give silent beacons', () => { + render(SoundPicker, { props: { sound: { ...CUSTOM_STATE, available: false }, onchange: () => {} } }) + expect(screen.getByText(/choose it again/i)).toBeTruthy() + }) + + it('asks the backend for a file and reports the change', async () => { + const calls: string[] = [] + mockFetch((url) => { + calls.push(url) + return { setting: 'radioSound', file: 'CTLD_beacon_custom.ogg', originalName: 'x.ogg', size: 12 } + }) + const onchange = vi.fn() + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange } }) + + await fireEvent.click(screen.getByRole('button')) + await waitFor(() => expect(onchange).toHaveBeenCalled()) + expect(calls).toEqual(['/api/sounds/radioSound/custom']) + }) + + it('does not report a change when the Mission Maker cancels the dialog', async () => { + mockFetch(() => ({ cancelled: true })) + const onchange = vi.fn() + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange } }) + + await fireEvent.click(screen.getByRole('button')) + await waitFor(() => expect(screen.getByRole('button')).not.toHaveProperty('disabled', true)) + expect(onchange).not.toHaveBeenCalled() + }) + + it('goes back to the bundled sound through its own endpoint', async () => { + const calls: string[] = [] + mockFetch((url) => { + calls.push(url) + return { setting: 'radioSound', file: 'beacon.ogg' } + }) + const onchange = vi.fn() + render(SoundPicker, { props: { sound: CUSTOM_STATE, onchange } }) + + const [dflt] = screen.getAllByRole('radio') + await fireEvent.click(dflt) + await waitFor(() => expect(onchange).toHaveBeenCalled()) + expect(calls).toEqual(['/api/sounds/radioSound/default']) + }) + + it('surfaces a refusal from the backend instead of failing silently', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + statusText: 'Unprocessable', + json: async () => ({ detail: 'music.ogg is not an Ogg file (no OggS signature)' }), + })), + ) + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange: () => {} } }) + + await fireEvent.click(screen.getByRole('button')) + await waitFor(() => expect(screen.getByText(/not an Ogg file/)).toBeTruthy()) + }) +}) diff --git a/tools/ctld-tools/web/src/lib/api.ts b/tools/ctld-tools/web/src/lib/api.ts index ac02f47..7157885 100644 --- a/tools/ctld-tools/web/src/lib/api.ts +++ b/tools/ctld-tools/web/src/lib/api.ts @@ -10,6 +10,11 @@ export interface SchemaKey { group: string | null standard: boolean choices: unknown[] | null + /** Named control when the value type is not enough to pick one (`sound` = beacon-sound picker). + * Optional like the rest of the schema's metadata: an entry that declares none is normal. */ + editor?: string | null + /** Written by the tool beside another setting — never listed as a setting of its own. */ + hidden?: boolean /** Authored display name in the active language; null → derive one from the key. */ label: string | null /** Authored unit symbol ("m", "s", "kg", …); null → fall back to reading the description. */ @@ -120,8 +125,38 @@ export type InstallResult = { triggers: string[] replacedPrevious: boolean changedSettings: number + sounds: InstalledSound[] } +export type InstalledSound = { + setting: string + file: string + size: number + custom: boolean +} + +/** What one beacon-sound picker shows: which source, which file, and whether it can be installed. */ +export type SoundState = { + setting: string + custom: boolean + file: string + /** The name the file had on the MM's disk — the reserved in-mission name would lose it. */ + originalName: string | null + size: number | null + available: boolean +} + +export const getSounds = () => fetch('/api/sounds').then((r) => json<{ sounds: SoundState[] }>(r)) + +/** Open the native picker and adopt the chosen file; `{cancelled:true}` when the MM backs out. */ +export const chooseSound = (setting: string) => + post(`/api/sounds/${setting}/custom`).then((r) => + json<{ cancelled?: boolean; setting?: string; file?: string; originalName?: string; size?: number }>(r), + ) + +export const resetSound = (setting: string) => + post(`/api/sounds/${setting}/default`).then((r) => json<{ setting: string; file: string }>(r)) + export const injectMiz = (miz: string) => post('/api/inject', { miz }).then((r) => json(r)) /** The CTLD version this build belongs to, and the docs version to link to (`dev` for an rc). */ diff --git a/tools/ctld-tools/web/src/lib/model.test.ts b/tools/ctld-tools/web/src/lib/model.test.ts index d9336e9..08edaca 100644 --- a/tools/ctld-tools/web/src/lib/model.test.ts +++ b/tools/ctld-tools/web/src/lib/model.test.ts @@ -16,8 +16,9 @@ const schema: SchemaInfo = { families: ['aa', 'troops', 'jtac'], familyMeta: {}, keys: { - numberOfTroops: { group: 'troops', standard: true, choices: null, label: null, unit: null, description: null }, - aaRearmDistance: { group: 'aa', standard: false, choices: null, label: null, unit: null, description: null }, + numberOfTroops: { group: 'troops', standard: true, choices: null, editor: null, hidden: false, label: null, unit: null, description: null }, + aaRearmDistance: { group: 'aa', standard: false, choices: null, editor: null, hidden: false, label: null, unit: null, description: null }, + radioSoundOriginalName: { group: 'general', standard: false, choices: null, editor: null, hidden: true, label: null, unit: null, description: null }, // hoverTime has no schema entry and no prefix rule → OTHER_FAMILY }, tableFields: {}, @@ -26,11 +27,12 @@ const schema: SchemaInfo = { const snap: Snapshot = { path: null, - keys: ['numberOfTroops', 'aaRearmDistance', 'hoverTime', 'spawnableCrates', 'transportPilotNames'], + keys: ['numberOfTroops', 'aaRearmDistance', 'hoverTime', 'radioSoundOriginalName', 'spawnableCrates', 'transportPilotNames'], values: { numberOfTroops: 10, aaRearmDistance: 300, hoverTime: 10, + radioSoundOriginalName: 'Ma Balise Été.ogg', spawnableCrates: { Support: [] }, transportPilotNames: ['Pilot #1'], }, @@ -58,7 +60,10 @@ describe('classify', () => { const placed = classify(snap, schema) .flatMap((f) => [...f.standard, ...f.advanced, ...f.data]) .sort() - expect(placed).toEqual([...snap.keys].sort()) + // Totality still holds, over the settings: the only exclusion is a key the schema marks + // `hidden`, i.e. one the tool writes beside another setting rather than one the MM edits. + const editable = snap.keys.filter((k) => !schema.keys[k]?.hidden).sort() + expect(placed).toEqual(editable) }) it('puts an unplaceable setting in `other` rather than dropping it', () => { @@ -68,12 +73,25 @@ describe('classify', () => { describe('settingKeys', () => { it('lists the scalar keys only — what search covers', () => { - expect(settingKeys(snap)).toEqual(['numberOfTroops', 'aaRearmDistance', 'hoverTime']) + expect(settingKeys(snap, schema)).toEqual(['numberOfTroops', 'aaRearmDistance', 'hoverTime']) + }) + + it('leaves out the tool-written labels, so search cannot offer one to edit', () => { + expect(settingKeys(snap, schema)).not.toContain('radioSoundOriginalName') + }) +}) + +describe('hidden keys', () => { + it('never land in a family — they are labels, not settings (ADR 0012)', () => { + const everywhere = classify(snap, schema).flatMap((f) => [...f.standard, ...f.advanced, ...f.data]) + expect(everywhere).not.toContain('radioSoundOriginalName') + // …and the setting they describe is still listed as usual + expect(snap.keys).toContain('radioSoundOriginalName') }) }) describe('editorType', () => { - const enumMeta: SchemaKey = { group: null, standard: false, choices: ['a', 'b'], label: null, unit: null, description: null } + const enumMeta: SchemaKey = { group: null, standard: false, choices: ['a', 'b'], editor: null, hidden: false, label: null, unit: null, description: null } it('resolves each scalar type', () => { expect(editorType(enumMeta, 'a')).toBe('enum') diff --git a/tools/ctld-tools/web/src/lib/model.ts b/tools/ctld-tools/web/src/lib/model.ts index 9e7d7de..2b66d75 100644 --- a/tools/ctld-tools/web/src/lib/model.ts +++ b/tools/ctld-tools/web/src/lib/model.ts @@ -41,6 +41,10 @@ export function classify(snap: Snapshot, schema: SchemaInfo): Family[] { } for (const key of snap.keys) { + // A `hidden` key is written by the tool beside another setting (the original name of a custom + // beacon sound, ADR 0012). It lives in the configuration but is not a setting: listing it would + // invite hand-editing a label into disagreeing with the sound it describes. + if (schema.keys[key]?.hidden) continue if (isStructured(snap.values[key])) { family(DATA_FAMILY[key] ?? OTHER_FAMILY).data.push(key) } else { @@ -58,9 +62,14 @@ export function classify(snap: Snapshot, schema: SchemaInfo): Family[] { return orderFamilies(byKey.keys(), schema.familyMeta).map((name) => byKey.get(name)!) } -/** Every scalar setting key in the catalogue, used by search. */ -export function settingKeys(snap: Snapshot): string[] { - return snap.keys.filter((k) => !isStructured(snap.values[k])) +/** + * Every scalar setting key in the catalogue, used by search. + * + * `schema` is optional only so old call sites keep working; pass it, or search will offer the + * hidden tool-written keys that `classify` deliberately leaves out of the families. + */ +export function settingKeys(snap: Snapshot, schema?: SchemaInfo): string[] { + return snap.keys.filter((k) => !isStructured(snap.values[k]) && !schema?.keys[k]?.hidden) } // ── scalar editors ──────────────────────────────────────────────── diff --git a/tools/ctld-tools/web/src/lib/strings.ts b/tools/ctld-tools/web/src/lib/strings.ts index c640eb6..97caf00 100644 --- a/tools/ctld-tools/web/src/lib/strings.ts +++ b/tools/ctld-tools/web/src/lib/strings.ts @@ -199,4 +199,13 @@ export const EN_STRINGS: Record = { 'web.slot.colour_b': "Blue", 'web.slot.colour_a': "Opacity", 'web.slot.colour_hint': "0.0 to 1.0", + // ── beacon sounds ─────────────────────────────────────────────── + 'web.sound.default': "Default", + 'web.sound.custom': "Custom", + 'web.sound.choose': "Choose a file…", + 'web.sound.replace': "Choose another file…", + 'web.sound.from': "from {name}", + 'web.sound.in_mission': "in the mission: {name}", + 'web.sound.missing': "The file is no longer loaded — choose it again, or switch back to the default.", + 'web.sound.not_ogg': "That file is not an Ogg sound, so DCS would play nothing.", }