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
28 changes: 28 additions & 0 deletions .backlog/FIX-CONFIG-NEW-LISTS/PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# FIX-CONFIG-NEW-LISTS — a list newer than the mission configuration is not a removal

Status: done — 2026-10-27 (PR #275)

## Problem

A mission configuration (`ctld.configUser`) omits a list on purpose to remove it (ADR 0011).
But a configuration written before a catalogue version never had the lists that version added.
The Fat Cow (catalogue `2.3.0`) added `fatCowTypes`, `fatCowCrates`, `fatCowLoadouts` and `fatCowAICows`: in a `2.2.0` configuration CTLD read them as removed, and the Fat Cow was on with no aircraft, no crate and no loadout.

Found on 2026-10-27 vendoring rc14 into VMCT: the VEAF Open Trainings' `ctld-config.yaml` date from `2.2.0`, and the `.miz` published that morning carried a `CTLD_userConfig.lua` without a single Fat Cow list.
`ctld-tools` had the same gap: its completion (ADR 0011 Addendum 2) adds scalars and entry fields, never a whole list, and saving stamps the new version, which makes the omission a removal for good.

## Decision

ADR 0011 Addendum 3.
The build dates every list from the pinned shapes (`ctld-tools since` → `ctld.configListSince`); the engine and `ctld-tools` give a list its default when the configuration's `configVersion` is older than the list.

## Definition of done

- `ctld.configListSince` generated by the build and merged into `CTLD.lua`.
- `CTLDConfig:load()` gives a newer list its default, localised, and names it in the startup notice; an omission at or after the list's version stays a removal.
- `complete()` adds such a list when `ctld-tools` opens the configuration.
- busted and pytest cover both sides; docs (mission-maker configuration EN + FR), ADR, CHANGELOG.

## Not claimed

Not flown: the Open Trainings were completed by hand in their own `ctld-config.yaml` on 2026-10-27 and run with that, not with this fix.
11 changes: 11 additions & 0 deletions .backlog/FIX-CONFIG-NEW-LISTS/tickets/01-date-the-lists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 01 — date the lists, default the newer ones

Status: done

See the [PRD](../PRD.md).

## Acceptance

- A `2.2.0` snapshot without `fatCowCrates` reads the catalogue's crates, reported by `getDefaultedParameters()`.
- A `2.3.0` snapshot without it reads nothing; `aiZones` (not dated) stays removed at any version.
- `complete()` adds `fatCowCrates` to a `2.2.0` configuration, not to a `2.3.0` one.
1 change: 1 addition & 0 deletions .backlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ authored **per lot, when the lot is started** (not in batch).

| Lot | Status | Description | Branch |
|-----|--------|-------------|--------|
| [`FIX-CONFIG-NEW-LISTS`](FIX-CONFIG-NEW-LISTS/PRD.md) | merged (PR #275) — 1 ticket ([date the lists](FIX-CONFIG-NEW-LISTS/tickets/01-date-the-lists.md)) | A list the catalogue gained after a mission configuration's version takes its default instead of reading as removed: the Fat Cow lists were empty on every `2.2.0` configuration (ADR 0011 Addendum 3). | `fix/config-new-lists-default` |
| [`FEAT-FAT-COW`](FEAT-FAT-COW/PRD.md) | merged (PR #273) — 06 waiting-human (live check with the squadron, Zip) — 01 spike ✅ (a runtime Invisible FARP serves, enforces a finite stock, and needs only a FARP Fuel Depot as supplier), 02-05 ✅ | A CH-47 that lands in the field and becomes a forward refuel/rearm point (AI escort of a flight, or player-flown with cow crates), stocked with what it carries. | `feature/fat-cow` |
| [`FIX-I18N-TRANSLATE-ALIAS`](FIX-I18N-TRANSLATE-ALIAS/PRD.md) | merged (PR #269) — 1 ticket ([JTAC through tr, guard sees the alias](FIX-I18N-TRANSLATE-ALIAS/tickets/01-jtac-through-tr-and-guard-sees-the-alias.md)) | The 16 JTAC radio keys reach the four dictionaries; the JTAC module calls ctld.tr and the generator also recognises the alias. |
| [`FIX-FARP-PACK-FALSE-SUCCESS`](FIX-FARP-PACK-FALSE-SUCCESS/PRD.md) | merged (PR #268) — 1 ticket ([report the crates created](FIX-FARP-PACK-FALSE-SUCCESS/tickets/01-report-the-crates-created.md)) | Packing a FARP now reports a pack that created no crate, or only some, instead of always announcing a success. |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ __pycache__/

# ── Generated verbatim YAML embed (ctld.configDefault; regenerated by the build) ──
/src/CTLD_config_default_yaml.lua
/src/CTLD_config_list_since.lua

# ── ctld-tools web app: built frontend assets (bundled at CI, CTLD-TOOLS-WEBAPP t07) ──
/tools/ctld-tools/ctld_tools/web/static/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Versioning follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed — a mission configuration written before the Fat Cow gets its crates and loadouts (FIX-CONFIG-NEW-LISTS)

- A mission configuration omits a list on purpose to remove it (ADR 0011), but a configuration written before
catalogue `2.3.0` never had the Fat Cow lists: CTLD read `fatCowTypes`, `fatCowCrates`, `fatCowLoadouts` and
`fatCowAICows` as removed, and the Fat Cow was on with no aircraft, crate or loadout (seen on the VEAF Open
Trainings, whose configurations date from `2.2.0`). The build now dates every list from the pinned catalogue
shapes (`ctld.configListSince`, `ctld-tools since`): a list newer than the configuration's `configVersion` takes
its default and is named in the startup notice, and `ctld-tools` adds it when it opens the configuration. A list
omitted at or after its own version is still a removal (ADR 0011 Addendum 3).

### Added — Fat Cow: a CH-47 that lands in the field and becomes a forward refuel and rearm point (FEAT-FAT-COW)

ED announced a native Fat Cow on 2024-04-19, and again on 2024-08-23; DCS 2.9.30.28738 still has none, so CTLD brings its own while the Chinooks wait.
Expand Down
36 changes: 36 additions & 0 deletions dev/adr/0011-complete-yaml-config-and-webapp-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,39 @@ version at `2.0.0` on both sides the version-gap detection reported nothing. The
- A completion only helps once an `ctld-tools.exe` that contains it is built and used: an older exe knows neither
the new settings nor the completion, and its own validation cannot flag what it does not know.
- Every change to the catalogue's keys or list-entry fields now costs a version increment and a snapshot file.

## Addendum 3 — a list newer than the configuration is not a removal (2026-10-27)

Point 1 reads an omitted list as a deliberate removal. That holds only for a list the configuration's own catalogue
already had. The Fat Cow (catalogue `2.3.0`) added `fatCowTypes`, `fatCowCrates`, `fatCowLoadouts` and
`fatCowAICows`; every mission configuration written against `2.2.0` lacks them, so the engine read them as removed
and the Fat Cow was on with no aircraft, no crate and no loadout.
Measured on the VEAF Open Trainings the day rc14 shipped: their `ctld-config.yaml` (VMCT, `2.2.0`) became
`ctld.configUser` without a single Fat Cow list.
Addendum 2's completion did not help either: it adds scalars and entry fields, never a whole list, and saving
stamps the new version, which turns the missing lists into removals for good.

### Decision

- **The build dates every list** from the pinned shapes (`tests/ci/data/catalogue_shapes`): `ctld-tools since`
writes `ctld.configListSince = { [list] = "<first version that has it>" }` next to `ctld.configDefault`, for the
lists that appeared after the oldest pinned shape.
- **The engine** gives such a list its default when the snapshot omits it and its `configVersion` is older than the
list (a snapshot with no version is older than any); the list is named in the startup notice with the omitted
parameters. A list the snapshot omits at or after its version stays a removal, as does any list the table does not
name.
- **`ctld-tools`** applies the same rule when it opens a configuration (`complete()`, reading the table from the
engine it installs), so saving keeps the list instead of stamping its absence.

### Considered options

- **Merge every list default at runtime.** Still rejected: it resurrects deliberate removals.
- **Fix it downstream, in each tool that writes configurations (VMCT's `ctld-upgrade`).** Rejected: a hand-written or
older configuration would keep the trap, and VMCT has no shapes to tell a new list from a removed one.

### Consequences

- The shapes become a build input, not only a test guard: a catalogue change without its snapshot now also leaves
the new lists undated, on top of failing the guard.
- Lists already in the oldest pinned shape (`2.1.0`) cannot be dated: an older configuration that lacks one still
reads it as removed.
4 changes: 4 additions & 0 deletions docs/mission-maker/configuration.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ réglages devenus inutiles, valeurs par défaut déplacées — sans jamais rien
- une **liste** (une section de caisses, un groupe de troupes, une entrée de zone, un nom de
pilote) que vous omettez est réellement absente. C'est ainsi qu'on en retire une — et c'est
pourquoi rien n'est fusionné.
Seule exception : une liste que CTLD a ajoutée après votre `configVersion` (les caisses et
chargements de la Fat Cow sont arrivés en `2.3.0`). Vous ne pouvez pas avoir retiré ce qui
n'existait pas encore : CTLD utilise sa liste par défaut et la nomme dans le même rapport de
démarrage. Ouvrir l'instantané dans `ctld-tools` l'ajoute pour de bon.

La validation de `ctld-tools` signale un réglage manquant comme une erreur et refuse d'injecter :
un instantané produit par l'outil est donc toujours complet.
Expand Down
4 changes: 4 additions & 0 deletions docs/mission-maker/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ used, defaults that moved — without ever merging anything behind your back.
still runs and still tells you.
- a **list** (a crate section, a troop group, a zone entry, a pilot name) you leave out is
genuinely absent. That is how you remove one — and it is why nothing is merged.
The one exception is a list CTLD added after your `configVersion` (the Fat Cow crates and
loadouts arrived in `2.3.0`): you cannot have removed what did not exist yet, so CTLD uses its
default list and names it in the same startup report. Opening the snapshot in `ctld-tools` adds it
for good.

`ctld-tools`' validation reports a missing setting as an error and refuses to inject, so a
snapshot produced by the tool is always complete.
Expand Down
18 changes: 15 additions & 3 deletions src/CTLD_config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,26 @@ function CTLDConfig:load()
self._defaultVersion = nil
if usingUser and ctld.configDefault then
self._defaults = CTLDConfig.flatten(CTLDConfig.parseYAML(ctld.configDefault))
self._userVersion = flat.configVersion
self._defaultVersion = self._defaults.configVersion
for k, v in pairs(self._defaults) do
if type(v) ~= "table" and flat[k] == nil then
self._defaultedParams[#self._defaultedParams + 1] = k
end
end
-- ADR 0011 Addendum 3 — a list the catalogue gained after the snapshot's version was not removed
-- by its author, who never saw it: it takes its default, reported like an omitted parameter.
-- A snapshot with no version predates every list in ctld.configListSince.
for k, since in pairs(ctld.configListSince or {}) do
local d = self._defaults[k]
if type(d) == "table" and flat[k] == nil
and (self._userVersion == nil or ctld.utils.compareVersions(self._userVersion, since) < 0) then
CTLDConfig.localiseI18n(d)
self.settings[k] = d
self._defaultedParams[#self._defaultedParams + 1] = k
end
end
table.sort(self._defaultedParams)
self._userVersion = flat.configVersion
self._defaultVersion = self._defaults.configVersion
end

return true, "CTLDConfig: loaded (" .. (usingUser and "user" or "default") .. " config)."
Expand Down Expand Up @@ -150,7 +162,7 @@ function CTLDConfig:reportRetiredSettings()
end
end

--- Parameters the loaded snapshot omitted, resolved from the default catalogue.
--- Parameters the loaded snapshot omitted, and lists newer than its version, resolved from the default catalogue.
-- Sorted; empty when the snapshot is complete, or when the default catalogue is the winner.
-- Reported once on screen at startup by CTLD_bootstrap (ADR 0011 Addendum 1).
-- @return table array of setting names
Expand Down
70 changes: 70 additions & 0 deletions tests/ci/unit/config_param_tier_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,76 @@ describe("CTLDConfig parameter/list tiers (ADR 0011 addendum 1)", function()
end)
end)

-- ── ADR 0011 Addendum 3: a list newer than the snapshot was never removed by its author ──
describe("a missing list newer than the snapshot's version takes its default", function()

local SINCE = { fatCowCrates = "2.3.0", fatCowLoadouts = "2.3.0" }
local savedSince

before_each(function()
savedSince = ctld.configListSince
ctld.configListSince = SINCE
end)
after_each(function()
ctld.configListSince = savedSince
end)

-- Drop `key` and its block (the `- ` items at its indent, and anything deeper).
local function withoutList(yaml, key)
local out, indent = {}, nil
for line in yaml:gmatch("[^\r\n]+") do
local lead = #line:match("^(%s*)")
if indent and (lead > indent or (lead == indent and line:match("^%s*%- "))) then
-- inside the dropped block
else
indent = nil
if line:match("^%s*" .. key .. "%s*:") then indent = lead else out[#out + 1] = line end
end
end
return table.concat(out, "\n")
end

local function atVersion(yaml, version)
return (yaml:gsub('configVersion: "[^"]*"', 'configVersion: "' .. version .. '"', 1))
end

it("a 2.2.0 snapshot without fatCowCrates gets the catalogue's crates", function()
local cfg = loadUser(atVersion(withoutList(DEFAULT, "fatCowCrates"), "2.2.0"))
local crates = cfg:getSetting("fatCowCrates")
assert.is_table(crates)
assert.is_true(#crates > 0)
assert.equals(2400, crates[1].fuelKg)
end)

it("reports the list among the settings taken from the default", function()
local cfg = loadUser(atVersion(withoutList(withoutList(DEFAULT, "fatCowCrates"), "fatCowLoadouts"), "2.2.0"))
assert.same({ "fatCowCrates", "fatCowLoadouts" }, cfg:getDefaultedParameters())
end)

it("a snapshot with no configVersion predates the list too", function()
local yaml = withoutList(DEFAULT, "fatCowCrates"):gsub('configVersion: "[^"]*"\n?', "", 1)
local cfg = loadUser(yaml)
assert.is_table(cfg:getSetting("fatCowCrates"))
end)

it("a 2.3.0 snapshot without fatCowCrates removed it: it stays absent", function()
local cfg = loadUser(withoutList(DEFAULT, "fatCowCrates"))
assert.is_nil(cfg:getSetting("fatCowCrates"))
assert.same({}, cfg:getDefaultedParameters())
end)

it("a list kept by the snapshot is the snapshot's, even when older", function()
local yaml = atVersion(DEFAULT, "2.2.0"):gsub("desc: Fat Cow fuel tank %(800 gal%)", "desc: My tank", 1)
local cfg = loadUser(yaml)
assert.equals("My tank", cfg:getSetting("fatCowCrates")[1].desc)
end)

it("a list the table does not name stays a removal at any version", function()
local cfg = loadUser(atVersion(without(DEFAULT, "aiZones"), "2.0.0"))
assert.is_nil(cfg:getSetting("aiZones"))
end)
end)

-- ── Ticket 03: a parameter must have exactly one default, in the catalogue ──
describe("no parameter carries a duplicate default in code", function()

Expand Down
1 change: 1 addition & 0 deletions tools/build/listToMerge.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CTLD_i18n_es.lua
CTLD_i18n_ko.lua
-- Verbatim config YAML embedded as ctld.configDefault string; parsed by CTLDConfig:load().
CTLD_config_default_yaml.lua
CTLD_config_list_since.lua
CTLD_utils.lua
CTLD_menu.lua
core/CTLD_objectRegistry.lua
Expand Down
6 changes: 6 additions & 0 deletions tools/build/merge_CTLD.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ Push-Location $ctldToolsDir
try {
& poetry run ctld-tools embed --yaml $configYaml --out $configDefaultLua --var configDefault
if ($LASTEXITCODE -ne 0) { throw "embed returned $LASTEXITCODE" }
# The version each list appeared in (ctld.configListSince, ADR 0011 Addendum 3): a mission config
# older than a list gets its default instead of reading its absence as a removal.
$configListSinceLua = Join-Path $srcDir "CTLD_config_list_since.lua"
$catalogueShapes = Join-Path (Split-Path $srcDir -Parent) "tests\ci\data\catalogue_shapes"
& poetry run ctld-tools since --yaml $configYaml --shapes $catalogueShapes --out $configListSinceLua
if ($LASTEXITCODE -ne 0) { throw "since returned $LASTEXITCODE" }
}
catch {
Write-Host "[ERROR] Could not embed CTLD_config.yaml via ctld-tools."
Expand Down
14 changes: 14 additions & 0 deletions tools/ctld-tools/ctld_tools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ def shape_cmd(
typer.echo(f"shape: wrote {out}")


@app.command("since")
def since_cmd(
yaml_path: Path = typer.Option(..., "--yaml", help="path to the catalogue YAML (src/CTLD_config.yaml)"),
shapes_dir: Path = typer.Option(..., "--shapes", help="the pinned shapes (tests/ci/data/catalogue_shapes)"),
out: Path = typer.Option(..., "--out", help="path to the Lua module to write (ctld.configListSince)"),
) -> None:
"""Write the catalogue version each list appeared in, as a Lua module (build step)."""
from ctld_tools.catalog import Catalog
from ctld_tools.since import write_since

write_since(Catalog.load(yaml_path), shapes_dir, out)
typer.echo(f"since: wrote {out} (ctld.configListSince)")


@app.command("validate")
def validate_cmd(
yaml_path: Path = typer.Option(..., "--yaml", help="path to a complete config YAML to validate"),
Expand Down
21 changes: 15 additions & 6 deletions tools/ctld-tools/ctld_tools/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@
only for entries the catalogue knows. Saving stamps the current version, so a later removal is
respected.

Lists and entries that are absent altogether are never re-created.
- A **whole list** absent is a removal (ADR 0011), except a list the catalogue gained after the
configuration's version (`since`, from `ctld.configListSince`, ADR 0011 Addendum 3): nobody could
have removed it, so it is added with its default, as the engine would read it.

Entries absent from a list are never re-created.

A value the configuration already carries is never changed.
"""

from __future__ import annotations

import copy
from dataclasses import dataclass
from typing import Any

Expand Down Expand Up @@ -46,25 +51,29 @@ def _is_scalar(value: Any) -> bool:
return not isinstance(value, (dict, list, tuple))


def complete(user: Catalog, default: Catalog) -> list[Addition]:
def complete(user: Catalog, default: Catalog, since: dict[str, str] | None = None) -> list[Addition]:
"""Add to `user` what `default` has and it lacks; return what was added.

Scalar parameters are always added. The tier is derived from the shape of the default value (a
scalar is a parameter), the same rule the engine applies, so tool and engine cannot disagree about
what an absent key means. Fields of list entries are added only when `user` predates `default`.
what an absent key means. Fields of list entries are added only when `user` predates `default`, and
a whole list only when `user` predates the version `since` gives it.
"""
added: list[Addition] = []
since = since or {}
user_version = parse_version(user.get(_VERSION_KEY))
for key in default.keys():
if key == _VERSION_KEY:
if key == _VERSION_KEY or user.has(key):
continue
value = default.get(key)
if not _is_scalar(value) or user.has(key):
if not _is_scalar(value) and not (key in since and user_version < parse_version(since[key])):
continue
section = default.section_of(key)
value = copy.deepcopy(value)
user.add_setting(key, value, section=section)
added.append(Addition(key, value, section))

if parse_version(user.get(_VERSION_KEY)) < parse_version(default.get(_VERSION_KEY)):
if user_version < parse_version(default.get(_VERSION_KEY)):
for f in entry_field_gap(user, default)[0]:
user.get(f.container)[f.entry][f.field] = f.value
added.append(Addition(f.field, f.value, user.section_of(f.container), f.container, f.entry))
Expand Down
Loading
Loading