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
2 changes: 1 addition & 1 deletion .backlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ authored **per lot, when the lot is started** (not in batch).

| Lot | Status | Description | Branch |
|-----|--------|-------------|--------|
| `FEAT-USERCONFIG-API` | ⬜ ready | Replace broken Section 2 of `CTLD_userConfig.lua` with a safe MM API (`ctld.userSetup` callbacks + helpers), relocate `injectAACrates` to bootstrap, fix all parity bugs in userConfig template. | |
| `FEAT-USERCONFIG-API` | ✅ merged (PR #45) | Replace broken Section 2 of `CTLD_userConfig.lua` with a safe MM API (`ctld.userSetup` callbacks + helpers), relocate `injectAACrates` to bootstrap, fix all parity bugs in userConfig template. | `feature/userconfig-api` |
| `CTLD-TOOLS-CONFIG` | ⬜ ready | Lot 2 of `ctld-tools` ([ADR 0009](../dev/adr/0009-external-yaml-authoring-ctld-tools.md)): move engine defaults out of `CTLD_config.lua` into `ctld-config.yaml` (source of truth, sectioned MM-facing/advanced); Python `ctld-tools` package + `gen-config` (YAML→Lua) as a build step; Lua-5.1 round-trip parity test guards the switch-over. MM volet / miz injection / TUI out of scope. | — |
| `CTLD-TOOLS-USERCONFIG` | ⬜ ready | Lot 3 of `ctld-tools` ([ADR 0009](../dev/adr/0009-external-yaml-authoring-ctld-tools.md)): MM volet — `validate` (user-config.yaml against the reference + datamine, clear report) + `gen-user` (compile add/delete/edit ops into `CTLD_userConfig.lua` calling the `ctld.userSetup` helpers) + `gen-user --scaffold` (commented starter); `ctld-tools.exe` attached to GitHub Releases. Depends on `FEAT-USERCONFIG-API` + `CTLD-TOOLS-CONFIG`. miz injection / TUI out of scope. | — |
| `DOC-README-CLEANUP` | ✅ merged (PR #21) | Fix README title, restructure Crate Operations sub-sections, relocate AA System Construction, replace Developer Guide with Documentation links. | — |
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ Versioning follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Feature — safe Mission Maker config API `ctld.userSetup` (FEAT-USERCONFIG-API)

- **New `ctld.userSetup` API**: Mission Makers customise the complex config tables from setup
callbacks instead of the silently-broken Section 2 of `CTLD_userConfig.lua` (which called
`CTLDConfig.get()` before CTLD had defined it). Helpers on `ctld`: `addCrate`, `removeCrate`,
`patchCrate` (deep-merge one level), `addTroopGroup`, `removeTroopGroup`, `addTo`, `logDefaults`.
Each callback runs guarded, so a failing one warns without aborting the others or the mission.
- **`injectAACrates` relocated** from `CTLDCrateManager:_processSpawnableCrates()` to
`ctld.initialize()` (before the userSetup callbacks), so the AA-system crate sections are visible
and patchable from a callback. `ctld.initialize()` is now the single place that materialises the
full config: defaults → AA injection → userSetup callbacks → managers.
- **`CTLD_userConfig.lua` template rewritten**: the broken Section 2 (direct `CTLDConfig.get()`
edits) is replaced by documented `ctld.userSetup` examples + per-table field schemas; the
test-only debug block (with its hardcoded `aiZones`) is removed; Section 1 scalar defaults
corrected (`parachuteMinAltitude*` = 152, `JTAC_droneAltitude` = 4000).
- **Docs**: `mission-maker/configuration.md` (EN + FR) updated to the callback-based flow.

### Tooling — release pre-release channel + `published-latest` (RELEASE-RC-CHANNEL)

- **`release.yml`**: a `-rc`-suffixed version (e.g. `published-v2.0.0-rc1`) now publishes the GitHub
Expand Down
203 changes: 199 additions & 4 deletions CTLD.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
--[[
CTLD.lua - Combined Transport and Logistics Dispatcher for DCS World
Version : 2.0.0-rc1
Built : 2026-07-19
Built : 2026-07-20
Source : https://github.com/VEAF/CTLD
Licence : MIT
DO NOT EDIT - generated by tools/build/merge_CTLD.ps1
Expand Down Expand Up @@ -1204,6 +1204,194 @@ CTLDConfig.reset() -- class method (dot notation)

-- End : CTLD_config.lua
-- ====================================================================================================
-- Start : CTLD_userSetup.lua
-- ============================================================
-- CTLD_userSetup.lua
-- Mission Maker configuration helper API (FEAT-USERCONFIG-API).
--
-- Surgical add/remove/patch operations on the live config, meant to be called
-- from `ctld.userSetup` callbacks. The bootstrap runs those callbacks after
-- CTLDConfig:load() and injectAACrates(), so AA entries are already present and
-- the whole default catalogue is patchable.
--
-- ctld.userSetup = ctld.userSetup or {}
-- table.insert(ctld.userSetup, function(cfg)
-- ctld.addCrate("Support", { weight = 2000.01, desc = "My Unit", unit = "Ural-375", side = 1 })
-- ctld.patchCrate(1001.01, { cratesRequired = 3 })
-- end)
--
-- Merged after CTLD_config.lua. Defines functions only — the ctld.utils.* calls
-- resolve at callback time (runtime), so load order beyond `ctld` is irrelevant.
-- ============================================================

ctld = ctld or {}

-- Live settings table of the config singleton.
local function _settings()
return CTLDConfig.get().settings
end

-- Locate a crate entry by weight across all spawnableCrates sections.
-- Returns entry, sectionTable, index (or nil).
local function _findCrate(weight)
local crates = _settings()["spawnableCrates"]
if type(crates) ~= "table" then return nil end
for _, entries in pairs(crates) do
for i, e in ipairs(entries) do
if e.weight == weight then return e, entries, i end
end
end
return nil
end

--- Add a crate entry to a spawnableCrates section (section created if absent).
-- A duplicate weight is fatal (irrecoverable F10-menu corruption): the entry is
-- rejected with a visible warning, but the mission is NOT aborted.
---@param section string
---@param entry table
---@return boolean added
function ctld.addCrate(section, entry)
local crates = _settings()["spawnableCrates"]
if type(crates) ~= "table" then
ctld.logWarning("ctld.addCrate: spawnableCrates unavailable — entry rejected")
return false
end
if entry and entry.weight and _findCrate(entry.weight) then
local msg = string.format(
"CTLD userConfig: duplicate crate weight %s — entry rejected", tostring(entry.weight))
ctld.logWarning("ctld.addCrate: %s", msg)
if trigger and trigger.action and trigger.action.outText then
trigger.action.outText(msg, 30)
end
return false
end
crates[section] = crates[section] or {}
table.insert(crates[section], entry)
return true
end

--- Remove a crate entry by weight (searched across all sections).
---@param weight number
---@return boolean removed
function ctld.removeCrate(weight)
local _, entries, idx = _findCrate(weight)
if not entries then
ctld.logWarning("ctld.removeCrate: no crate with weight %s — nothing removed", tostring(weight))
return false
end
table.remove(entries, idx)
return true
end

--- Patch a crate entry found by weight. Shallow merge at the top level, plus a
-- one-level deep merge for table-valued fields (e.g. specificParams), so a single
-- nested field can be changed without rewriting the whole sub-table.
---@param weight number
---@param patch table
---@return boolean patched
function ctld.patchCrate(weight, patch)
local entry = _findCrate(weight)
if not entry then
ctld.logWarning("ctld.patchCrate: no crate with weight %s — nothing patched", tostring(weight))
return false
end
for k, v in pairs(patch or {}) do
if type(v) == "table" and type(entry[k]) == "table" then
for kk, vv in pairs(v) do
entry[k][kk] = vv
end
else
entry[k] = v
end
end
return true
end

--- Add an infantry group template to loadableGroups.
---@param entry table
---@return boolean added
function ctld.addTroopGroup(entry)
local groups = _settings()["loadableGroups"]
if type(groups) ~= "table" then
ctld.logWarning("ctld.addTroopGroup: loadableGroups unavailable — entry rejected")
return false
end
table.insert(groups, entry)
return true
end

--- Remove an infantry group template by name.
---@param name string
---@return boolean removed
function ctld.removeTroopGroup(name)
local groups = _settings()["loadableGroups"]
if type(groups) == "table" then
for i, g in ipairs(groups) do
if g.name == name then
table.remove(groups, i)
return true
end
end
end
ctld.logWarning("ctld.removeTroopGroup: no group named '%s' — nothing removed", tostring(name))
return false
end

--- Append an entry to an array-type setting (transportPilotNames, troopZones,
-- wpZones, extractableGroups, logisticUnits, ...).
---@param settingName string
---@param entry any
---@return boolean added
function ctld.addTo(settingName, entry)
local value = _settings()[settingName]
if type(value) ~= "table" then
ctld.logWarning("ctld.addTo: setting '%s' is not an array — nothing added", tostring(settingName))
return false
end
table.insert(value, entry)
return true
end

--- Serialise a config setting to CTLD.log in copy-pasteable Lua form, so the MM
-- can inspect the real defaults at runtime. Intended to be called from a ME
-- DO SCRIPT trigger a few seconds after mission start.
---@param settingName string
function ctld.logDefaults(settingName)
local value = ctld.gs(settingName)
if value == nil then
ctld.logWarning("ctld.logDefaults: unknown setting '%s'", tostring(settingName))
return
end
local out
if type(value) == "table" then
out = ctld.utils.tableShowScript("ctld.logDefaults", value, settingName)
else
out = settingName .. " = " .. ctld.utils.basicSerialize("ctld.logDefaults", value)
end
ctld.utils.log("INFO", "ctld.logDefaults(%s):\n%s", tostring(settingName), out)
end

--- Run every registered ctld.userSetup callback, in registration order, against the
-- live config singleton. Each callback is guarded so a failure in one is reported
-- and neither aborts the other callbacks nor the mission (FEAT-USERCONFIG-API US 11).
-- Called by ctld.initialize() after injectAACrates and before the managers boot.
function ctld.runUserSetup()
local callbacks = ctld.userSetup or {}
local cfg = CTLDConfig.get()
for i, cb in ipairs(callbacks) do
if type(cb) == "function" then
local ok, err = pcall(cb, cfg)
if not ok then
ctld.logWarning("ctld.userSetup callback #%d failed: %s", i, tostring(err))
end
else
ctld.logWarning("ctld.userSetup entry #%d is not a function — skipped", i)
end
end
end

-- End : CTLD_userSetup.lua
-- ====================================================================================================
-- Start : CTLD_i18n.lua
--[[
CTLD — Internationalization class (CTLDi18n)
Expand Down Expand Up @@ -11467,9 +11655,8 @@ end
-- Results stored in self._processedCrates[category] and self._weightIndex[weight].
function CTLDCrateManager:_processSpawnableCrates()
local spawnableCrates = ctld.gs("spawnableCrates") or {}
-- Inject AA system crate entries (parts + repair) from CTLDCrateAssemblyManager.TEMPLATES.
-- Must run before the main loop so injected entries enter _processedCrates and _weightIndex.
CTLDCrateAssemblyManager.injectAACrates(spawnableCrates)
-- AA system crate entries are injected once by ctld.initialize() (before the userSetup
-- callbacks), so spawnableCrates already contains them here — see ADR 0008/0009.
local showCrateSets = ctld.gs("enableAllCrates") ~= false
local allSuffix = " - " .. ctld.tr("All crates")

Expand Down Expand Up @@ -24290,6 +24477,14 @@ function ctld.initialize()
CTLDConfig.get():load()
ctld.utils.initLog()

-- Materialise the full configuration before any manager reads it:
-- 1) inject the AA system crate entries into spawnableCrates,
-- 2) run the Mission Maker userSetup callbacks (add/remove/patch).
-- This is the single place that controls config materialisation order, so managers
-- always see the final, complete config table (ADR 0008/0009).
CTLDCrateAssemblyManager.injectAACrates(ctld.gs("spawnableCrates"))
ctld.runUserSetup()

-- Boot all domain managers first so they can register their menu sections.
-- Order matters: PlayerManager must be up before any other manager calls
-- registerMenuSection(), and _scanExistingPlayers() must run last so all
Expand Down
41 changes: 28 additions & 13 deletions docs/mission-maker/configuration.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,29 @@ ctld.slingLoad: true
]]
```

Les **tables** (capacités d'appareils, listes de zones, catalogues de crate…) sont affectées
directement sur l'instance de configuration. Chaque table que vous affectez **remplace**
entièrement sa valeur par défaut :
Les **tables** (catalogues de crate, groupes de troupes, listes de zones, capacités d'appareils…)
se personnalisent depuis un ou plusieurs **callbacks de configuration** enregistrés dans
`ctld.userSetup`. CTLD exécute chaque callback une fois à l'initialisation, après la mise en place
des valeurs par défaut (et des crates AA auto-injectées) — vous modifiez donc le vrai catalogue de
façon chirurgicale au lieu de le recopier et de le maintenir en entier :

```lua
local _cfg = CTLDConfig.get()
_cfg.settings["capabilitiesByType"] = { --[[ ... ]] }
ctld.userSetup = ctld.userSetup or {}
table.insert(ctld.userSetup, function(cfg)
ctld.addCrate("Support", { weight = 2000.01, desc = "Ural Ammo", unit = "Ural-375", side = 1 })
ctld.removeCrate(1000.05) -- retire une crate par défaut par son poids
ctld.patchCrate(1000.02, { cratesRequired = 3 }) -- change un champ, garde le reste
ctld.addTroopGroup({ name = "Recon Team", inf = 3, jtac = 1 })
ctld.addTo("transportPilotNames", "helicargo_custom_1")
end)
```

Les helpers — `ctld.addCrate`, `ctld.removeCrate`, `ctld.patchCrate`, `ctld.addTroopGroup`,
`ctld.removeTroopGroup`, `ctld.addTo` — opèrent sur la configuration vivante. Les dictionnaires
sans helper (`capabilitiesByType`, `aiZones`, …) s'éditent directement sur l'argument `cfg`. Appelez
`ctld.logDefaults("spawnableCrates")` depuis un trigger de mission pour écrire les valeurs par
défaut courantes dans `CTLD.log`, et consultez `CTLD_userConfig.lua` pour le modèle complet documenté.

## Réglages globaux

### Système
Expand Down Expand Up @@ -232,8 +246,8 @@ appareils listés ici reçoivent les menus F10 de CTLD.** Chaque clé est le **n
exact** de l'appareil (mods inclus, ex. `"Hercules"`, `"76MD"`, `"UH-60L"`).

```lua
local _cfg = CTLDConfig.get()
_cfg.settings["capabilitiesByType"] = {
-- dans un callback ctld.userSetup : table.insert(ctld.userSetup, function(cfg) ... end)
cfg.settings["capabilitiesByType"] = {
["UH-1H"] = {
cratesEnabled = true, -- can load/unpack crates
troopsEnabled = true, -- can load/deploy infantry
Expand Down Expand Up @@ -286,9 +300,10 @@ _cfg.settings["capabilitiesByType"] = {
charge ensuite depuis le cockpit. Voir le [guide Pilote](../pilot/index.md) pour le
déroulement en cockpit.

Les appareils **non** listés dans `capabilitiesByType` ne reçoivent aucun menu CTLD. Affecter
votre propre table `capabilitiesByType` remplace entièrement celle intégrée, donc incluez chaque
appareil que vous voulez rendre CTLD-capable.
Les appareils **non** listés dans `capabilitiesByType` ne reçoivent aucun menu CTLD. Affecter la
table `capabilitiesByType` entière remplace celle intégrée, donc incluez chaque appareil que vous
voulez rendre CTLD-capable — ou modifiez un seul champ pour conserver les défauts, p. ex.
`cfg.settings["capabilitiesByType"]["Mi-8MT"].maxTroopsOnboard = 20`.

## Contrôle d'accès

Expand All @@ -309,9 +324,9 @@ ensemble fixe de slots nommés (ex. une escadrille de transport dédiée). Les a
CTLD-capables **non** listés rejoignent la mission normalement mais n'ont aucun accès CTLD.

```lua
local _cfg = CTLDConfig.get()
_cfg.settings["addPlayerAircraftByType"] = false
_cfg.settings["transportPilotNames"] = {
-- dans un callback ctld.userSetup : table.insert(ctld.userSetup, function(cfg) ... end)
cfg.settings["addPlayerAircraftByType"] = false
cfg.settings["transportPilotNames"] = {
"transport_slot_1",
"transport_slot_2",
"transport_slot_3",
Expand Down
Loading