diff --git a/.backlog/POST-FULLGAS-FIXES/PRD.md b/.backlog/POST-FULLGAS-FIXES/PRD.md new file mode 100644 index 0000000..deaaca8 --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/PRD.md @@ -0,0 +1,36 @@ +# Lot POST-FULLGAS-FIXES β€” apply FullGas's review answers + +Status: 🚧 in progress +Branch: fix/post-fullgas-corrections β†’ PR β†’ develop +Program: re-tooling CTLD on the VMCT model (see `.backlog/README.md`). Follows +`REINTEGRATE-ORPHAN-TESTS`; sibling to the (still-open) `CLEANUP-LEGACY-DCS-TESTS`. + +## Problem Statement + +FullGas reviewed our `dev/fullgas-report.md` findings and answered each one. Several are not +"design questions to defer" but concrete corrections we can now apply β€” one is a real product +regression, the rest are stale tests to rewrite against the current design, plus a mission-content +gap and doc touch-ups. + +## Tickets + +| # | Scope | Nature | +|---|-------|--------| +| 01 | F-Q whole-vehicle regression: restore Feature Q `loadableList` in `refreshRequestEquipmentSection` (dropped by commit 0a15814, ref b1ddfe4) | **src** + rebuild | +| 02 | U-108 ProbeOffMap + ProbeLifeCheck: run-once guard (FullGas option A) | test | +| 03 | U-108 WarnAndSkip: rewrite β€” assert `Farp_FG_Petit_Helipad` (probeSkip) excluded, 3 stock heliports present as HELIPORT, INFO "skipped" emitted | test | +| 04 | F-117/F-118 recon: `reconEnabled`β†’`reconF10Menu` + keyword; `nil`β†’`~=nil` (scan stays active) + targets empty | test | +| 05 | fr_ai_zones: impl `pickMaxStock = entry.isPickup and 0 or nil` + G3 extended (WARN on scalar/empty-table troopStock, incl. -1 legacy) + rewrite the 7 checks | src + test | +| 06 | Re-add static `coord_farp-1` (group "coord_farp", M92 barrel) to `Test_CTLDNEXT_01.miz` | mission | +| 07 | README doc: title "DCS-CTLD Next"β†’"CTLD"; "Pack Equipt" under "Crate Operations"; "Developer documentation"β†’"Guides" (link to the guides site) | doc | + +## Decisions + +- **F-R-3** (ticket 05): `troopStock = -1` legacy scalar β†’ emit the G3 WARN "invalid format" (guide + the mission-maker to `{All=-1}`), rather than tolerate silently. Confirmed by David. + +## Non-goals + +- Porting/purging the 194 relics β€” that's `CLEANUP-LEGACY-DCS-TESTS` (port-to-VEAF then delete all). +- Any commit reference from FullGas's fork (0a15814, b1ddfe4): those objects exist in our repo but + are NOT in develop's lineage β€” used as read-only parity references, never cherry-picked. diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/01-fq-regression.md b/.backlog/POST-FULLGAS-FIXES/tickets/01-fq-regression.md new file mode 100644 index 0000000..435dbb6 --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/01-fq-regression.md @@ -0,0 +1,11 @@ +# 01 β€” F-Q whole-vehicle regression (Feature Q) + +Status: βœ… done +Type: AFK + +Commit 0a15814 (DCS-cargo fix, FullGas fork) dropped the `loadableList` computation in +`CTLDCrateManager:refreshRequestEquipmentSection` and hardcoded `spawnAsVehicle = false`, +silently disabling whole-vehicle spawn from Request Equipment. Restored the logic from the +b1ddfe4 reference (both objects exist in our repo, read-only). src + rebuild CTLD.lua. + +Validated live: scenario_fq_vehicle_whole_transport PASS 9/9 (was 1/9). diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/02-u108-runonce.md b/.backlog/POST-FULLGAS-FIXES/tickets/02-u108-runonce.md new file mode 100644 index 0000000..22a0db8 --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/02-u108-runonce.md @@ -0,0 +1,10 @@ +# 02 β€” U-108 ProbeOffMap + ProbeLifeCheck + +Status: βœ… done +Type: AFK + +- ProbeLifeCheck: run-once guard (FullGas option A). PASS 4/4 live. +- ProbeOffMap: run-once guard + C3 rewritten as option C β€” verify the valid + probe's own ghost (CTLD_MVP_H) exists by name instead of a before/after + count-diff (which collided with the ghosts CTLD init pre-creates). PASS 4/4 live. + Resolved ourselves (David's call), no FullGas decision needed. diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/03-u108-warnandskip.md b/.backlog/POST-FULLGAS-FIXES/tickets/03-u108-warnandskip.md new file mode 100644 index 0000000..7cc8846 --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/03-u108-warnandskip.md @@ -0,0 +1,9 @@ +# 03-u108-warnandskip + +Status: βœ… done +Type: AFK + +U-108 WarnAndSkip rewrite vs current heliport registry + +## Blocked by +Ticket 01 (branch established). diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/04-recon-f117-f118.md b/.backlog/POST-FULLGAS-FIXES/tickets/04-recon-f117-f118.md new file mode 100644 index 0000000..346190b --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/04-recon-f117-f118.md @@ -0,0 +1,9 @@ +# 04-recon-f117-f118 + +Status: βœ… done +Type: AFK + +F-117/F-118 recon: reconF10Menu gate + scan-stays-active rewrite + +## Blocked by +Ticket 01 (branch established). diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/05-fr-ai-zones.md b/.backlog/POST-FULLGAS-FIXES/tickets/05-fr-ai-zones.md new file mode 100644 index 0000000..c50544d --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/05-fr-ai-zones.md @@ -0,0 +1,9 @@ +# 05-fr-ai-zones + +Status: βœ… done +Type: AFK + +fr_ai_zones pickMaxStock impl + G3 extended WARN + 7 checks + +## Blocked by +Ticket 01 (branch established). diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/06-coord-farp-miz.md b/.backlog/POST-FULLGAS-FIXES/tickets/06-coord-farp-miz.md new file mode 100644 index 0000000..44234bb --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/06-coord-farp-miz.md @@ -0,0 +1,9 @@ +# 06-coord-farp-miz + +Status: βœ… done +Type: AFK + +Re-add static coord_farp-1 to Test_CTLDNEXT_01.miz + +## Blocked by +Ticket 01 (branch established). diff --git a/.backlog/POST-FULLGAS-FIXES/tickets/07-readme-doc.md b/.backlog/POST-FULLGAS-FIXES/tickets/07-readme-doc.md new file mode 100644 index 0000000..bd82ea7 --- /dev/null +++ b/.backlog/POST-FULLGAS-FIXES/tickets/07-readme-doc.md @@ -0,0 +1,9 @@ +# 07 β€” README doc touch-ups + +Status: ⏸ deferred +Type: doc + +FullGas's README observations (title "DCS-CTLD Next"β†’"CTLD", "Pack Equipt" under +"Crate Operations", "Developer documentation"β†’"Guides") target the monolithic +root README.md. Per David: DON'T patch it piecemeal β€” the whole README will be +rewritten later. Deferred out of this lot. diff --git a/.claude/skills/integration-testing/SKILL.md b/.claude/skills/integration-testing/SKILL.md index 190627b..8681b10 100644 --- a/.claude/skills/integration-testing/SKILL.md +++ b/.claude/skills/integration-testing/SKILL.md @@ -36,18 +36,27 @@ Once these are up, `exec_lua` is available as an MCP tool. ## The injection loop ``` -Modify src/ β†’ Rebuild (if src/ changed) β†’ exec_lua CTLD.lua β†’ wait ~3-5s for init +Modify src/ β†’ Rebuild (if src/ changed) β†’ reload mission (Shift+R, if src/ changed) β†’ exec_lua the scenario β†’ read the verdict β†’ iterate on FAIL ``` 1. **Rebuild** if `src/` changed: `powershell -ExecutionPolicy Bypass -File tools\build\merge_CTLD.ps1` -2. **Inject CTLD.lua**: `exec_lua(code=)`, then wait 3-5s (CTLD init is - async β€” `CTLDCoreManager` and friends aren't available immediately). -3. **Inject the scenario**: `exec_lua(code=)`. +2. **Load the new CTLD code** β€” how depends on whether `src/` changed: + - **Scenario-only change** (no `src/` change): nothing to reload β€” CTLD is already in memory, + just inject the scenario (step 3). + - **`src/` changed** (so `CTLD.lua` was rebuilt): **ask the user to reload the mission with + `Shift+R`** in DCS. This restarts the mission from a clean state and re-`dofile`s the fresh + `CTLD.lua` from disk (the MISSION START trigger does this). + ⚠️ Do **NOT** inject the whole `CTLD.lua` via `exec_lua`/`--inject-ctld`: the file is ~1.2 MB + and a single exec **times out (HTTP 504)**. And do **NOT** `dofile("…/CTLD.lua")` over a live + mission β€” it re-runs CTLD init (scheduler, event handlers, timers) on top of the existing + one and **freezes the DCS Lua thread**. Reloading the mission is the only safe way. +3. **Inject the scenario**: `exec_lua(code=)` (small file β€” fine). 4. **Read the verdict** β€” see the return contract below. 5. **Iterate**: on FAIL, fix and re-inject without waiting for the user (autonomous debug loop β€” - the user does not need to be in the loop between injections). + the user does not need to be in the loop between injections). If the fix is in `src/`, a + mission reload (`Shift+R`) is needed again before re-testing. You (the AI) drive this loop end to end. Only pause for the user when a scenario requires a human F10 action it cannot itself take (see pilotActive scenarios). diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ad777..886ff91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Bug fixes (FullGas review round) + +- **Fix**: whole-vehicle spawn from the **Request Equipment** menu (Feature Q) was silently + disabled β€” `refreshRequestEquipmentSection` hardcoded `spawnAsVehicle=false` after a DCS-cargo + refactor dropped the loadable-vehicle detection. Restored: a transport with + `canTransportWholeVehicle` again spawns a whole vehicle for its loadable types. +- **Fix**: AI-zone stock validation β€” dropoff-only zones no longer receive a bogus + `pickMaxStock`, and an invalid `troopStock` (a legacy scalar like `0`/`-1`/`10`, or an empty + table, instead of a `{[templateName]=N}` table) now emits a clear config WARN. + ### DCS integration testing β€” first live validation - **Fix**: `missions/Test_CTLDNEXT_01.miz`'s embedded `beacon.ogg` (420KB) broke the DCS Mission diff --git a/CTLD.lua b/CTLD.lua index a673418..4b717ba 100644 --- a/CTLD.lua +++ b/CTLD.lua @@ -4,7 +4,7 @@ --[[ CTLD.lua - Combined Transport and Logistics Dispatcher for DCS World Version : 2.0.0 - Built : 2026-07-09 + Built : 2026-07-12 Source : https://github.com/VEAF/CTLD Licence : MIT DO NOT EDIT - generated by tools/build/merge_CTLD.ps1 @@ -1888,7 +1888,7 @@ ctld.i18n["en"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["en"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored" ctld.i18n["en"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T" ctld.i18n["en"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP" -ctld.i18n["en"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled" +ctld.i18n["en"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table" ctld.i18n["en"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled" ctld.i18n["en"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups" ctld.i18n["en"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped" @@ -2365,7 +2365,7 @@ ctld.i18n["fr"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["fr"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] ERREUR '%2' : cargoType '%3' nΓ©cessite un transport de vΓ©hicule entier mais aucun aΓ©ronef n'a canTransportWholeVehicle=true β€” entrΓ©e ignorΓ©e" ctld.i18n["fr"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] AVERT '%2' : cargoType '%3' invalide β€” valeur par dΓ©faut T" ctld.i18n["fr"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] AVERT '%2' : aiDropMode '%3' invalide β€” valeur par dΓ©faut GP" -ctld.i18n["fr"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] AVERT '%2' : isPickup=true avec cargo de troupes mais troopStock non dΓ©fini β€” pickup de troupes dΓ©sactivΓ©" +ctld.i18n["fr"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] AVERT '%2' : isPickup=true avec cargo de troupes mais troopStock nil/invalide β€” utiliser une table {[nomTemplate]=N}" ctld.i18n["fr"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] AVERT '%2' : isPickup=true avec cargo de vΓ©hicule mais vehicleStock non dΓ©fini β€” pickup de vΓ©hicule dΓ©sactivΓ©" ctld.i18n["fr"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] AVERT '%2' : troopTemplates['%3'] introuvable dans loadableGroups" ctld.i18n["fr"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] AVERT '%2' : tous les troopTemplates sont inconnus β€” le pickup de troupes sera toujours ignorΓ©" @@ -2843,7 +2843,7 @@ ctld.i18n["es"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["es"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] ERROR '%2': cargoType '%3' requiere transporte de vehΓ­culo completo pero ninguna aeronave tiene canTransportWholeVehicle=true β€” entrada ignorada" ctld.i18n["es"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] AVISO '%2': cargoType '%3' invΓ‘lido β€” predeterminado a T" ctld.i18n["es"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] AVISO '%2': aiDropMode '%3' invΓ‘lido β€” predeterminado a GP" -ctld.i18n["es"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] AVISO '%2': isPickup=true con carga de tropas pero troopStock no definido β€” carga de tropas desactivada" +ctld.i18n["es"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] AVISO '%2': isPickup=true con carga de tropas pero troopStock nil/invΓ‘lido β€” usar una tabla {[nombreTemplate]=N}" ctld.i18n["es"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] AVISO '%2': isPickup=true con carga de vehΓ­culo pero vehicleStock no definido β€” carga de vehΓ­culo desactivada" ctld.i18n["es"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] AVISO '%2': troopTemplates['%3'] no encontrado en loadableGroups" ctld.i18n["es"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] AVISO '%2': todos los troopTemplates son desconocidos β€” el pickup de tropas siempre se omitirΓ‘" @@ -3171,7 +3171,7 @@ ctld.i18n["ko"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["ko"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] 였λ₯˜ '%2': cargoType '%3'은(λŠ”) μ°¨λŸ‰ 전체 μˆ˜μ†‘μ΄ ν•„μš”ν•˜μ§€λ§Œ canTransportWholeVehicle=true인 항곡기가 μ—†μŒ β€” ν•­λͺ© λ¬΄μ‹œλ¨" ctld.i18n["ko"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] κ²½κ³  '%2': cargoType '%3' 잘λͺ»λ¨ β€” κΈ°λ³Έκ°’ T" ctld.i18n["ko"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] κ²½κ³  '%2': aiDropMode '%3' 잘λͺ»λ¨ β€” κΈ°λ³Έκ°’ GP" -ctld.i18n["ko"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] κ²½κ³  '%2': isPickup=true이고 병λ ₯ ν™”λ¬Όμ΄μ§€λ§Œ troopStock λ―Έμ •μ˜ β€” 병λ ₯ ν”½μ—… λΉ„ν™œμ„±ν™”" +ctld.i18n["ko"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] κ²½κ³  '%2': isPickup=true이고 병λ ₯ ν™”λ¬Όμ΄μ§€λ§Œ troopStock nil/무효 β€” {[templateName]=N} ν…Œμ΄λΈ” μ‚¬μš©" ctld.i18n["ko"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] κ²½κ³  '%2': isPickup=true이고 μ°¨λŸ‰ ν™”λ¬Όμ΄μ§€λ§Œ vehicleStock λ―Έμ •μ˜ β€” μ°¨λŸ‰ ν”½μ—… λΉ„ν™œμ„±ν™”" ctld.i18n["ko"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] κ²½κ³  '%2': troopTemplates['%3']을(λ₯Ό) loadableGroupsμ—μ„œ 찾을 수 μ—†μŒ" ctld.i18n["ko"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] κ²½κ³  '%2': λͺ¨λ“  troopTemplatesλ₯Ό μ•Œ 수 μ—†μŒ β€” 병λ ₯ 픽업이 항상 κ±΄λ„ˆλœ€" @@ -8089,7 +8089,9 @@ function CTLDZoneManager:_loadAIZonesFromConfig() isAIPickup = entry.isPickup == true, isAIDropoff = entry.isDropoff == true, aiCargoType = entry.cargoType or "T", - pickMaxStock = 0, -- unlimited; per-template stock via _aiTroopStock + -- pickup zones: 0 = unlimited (per-template stock lives in _aiTroopStock); + -- dropoff-only zones: nil, so hasPickup() stays false (FullGas fix, F-R-2/13.8). + pickMaxStock = entry.isPickup and 0 or nil, troopTemplates = troopTemplates, vehicleTypes = (entry.vehicleTypes and #entry.vehicleTypes > 0) and entry.vehicleTypes or nil, @@ -8866,10 +8868,14 @@ function CTLDZoneManager:_validateZoneNames() if entry.aiDropMode and not VALID_DROP_MODE[entry.aiDropMode] then warns[#warns + 1] = ctld.tr(" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP", i, tostring(dzn), tostring(entry.aiDropMode)) end - -- G3: isPickup + troop cargo + troopStock not defined β†’ troop pickup disabled + -- G3: isPickup + troop cargo + troopStock nil OR not a {[name]=N} table (incl. a legacy + -- scalar like 0/-1/10, or an empty table) β†’ invalid format, troop pickup disabled. local effCargoHasTroops = (not entry.cargoType or entry.cargoType == "T" or entry.cargoType == "TV") - if not hasErr and entry.isPickup and effCargoHasTroops and entry.troopStock == nil then - warns[#warns + 1] = ctld.tr(" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled", i, tostring(dzn)) + local troopStockInvalid = entry.troopStock == nil + or type(entry.troopStock) ~= "table" + or next(entry.troopStock) == nil + if not hasErr and entry.isPickup and effCargoHasTroops and troopStockInvalid then + warns[#warns + 1] = ctld.tr(" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table", i, tostring(dzn)) end -- G6: isPickup + vehicle cargo + vehicleStock not defined β†’ vehicle pickup disabled local effCargoHasVehicle = (entry.cargoType == "V" or entry.cargoType == "TV") @@ -13930,6 +13936,17 @@ function CTLDCrateManager:refreshRequestEquipmentSection(playerObj) local showSets = ctld.gs("enableAllCrates") ~= false local processed = self._processedCrates or {} + -- Feature Q: pre-compute loadable whole-vehicle types for this transport (nil when not + -- capable). Restored from b1ddfe4 β€” commit 0a15814 (DCS-cargo fix) dropped this list and + -- hardcoded spawnAsVehicle=false, silently disabling whole-vehicle spawn from Request + -- Equipment (regression confirmed by FullGas; scenario_fq_vehicle_whole_transport F-Q-5). + local loadableList = nil + if caps.canTransportWholeVehicle then + loadableList = (playerObj.coalition == coalition.side.RED) + and caps.loadableVehiclesRED + or caps.loadableVehiclesBLUE + end + -- Shared spawn callback: handles both single crate (arg.unit) and set (arg.multiple). local function spawnFn(arg) local t = Unit.getByName(arg.unitName) @@ -13997,9 +14014,13 @@ function CTLDCrateManager:refreshRequestEquipmentSection(playerObj) local sc = entry.singleCrate local sideOk = (sc.side == nil) or (sc.side == playerObj.coalition) if sideOk and (not _crateIsJTAC(sc) or jtacOk) then - -- Request Equipment always spawns crates, never a whole vehicle. - -- Feature Q whole-vehicle spawn is handled by the dedicated "Load Vehicle" menu. + -- Feature Q: detect if this item should spawn a whole vehicle local spawnAsVehicle = false + if loadableList then + for _, ltype in ipairs(loadableList) do + if ltype == sc.unit then spawnAsVehicle = true; break end + end + end crateOrder = crateOrder + 1 menu:addCommand({ root, spawnSub, lgzName, category }, sc.desc, spawnFn, diff --git a/dev/fullgas-report.md b/dev/fullgas-report.md index 2533695..451888e 100644 --- a/dev/fullgas-report.md +++ b/dev/fullgas-report.md @@ -98,3 +98,25 @@ context, intent behind legacy test/mission design) β€” not published, internal w such static exists in `missions/Test_CTLDNEXT_01.miz`. Needs a static named `coord_farp-1` added to the mission via the Mission Editor β€” not something to guess-place without knowing the intended location. David's call: note for FullGas rather than block on it now. + +## Round 2 β€” follow-ups after FullGas's answers + +- **U-108 ProbeOffMap (C3) β€” option A (run-once guard) is NOT enough** (found 2026-07-12, live + validation of the applied fixes). FullGas's answer assumed "run 1, before=0". But in + `Test_CTLDNEXT_01` the CTLD init (modValidator) already probes heliports at mission load and + leaves **7 `CTLD_MVP_` ghost airbases** present before the test runs. The test resets the + singleton (`_probeIdxβ†’0`) and re-probes with the **same name** (`CTLD_MVP_H1`), which DCS + rejects as a duplicate β†’ the before/after count diff is 0 β†’ C3 fails **even on the first run**. + The run-once guard (applied, ticket 02) prevents *accumulation across re-runs* but cannot fix + the *first-run* collision with the init's ghosts. So option A alone doesn't make ProbeOffMap + pass here. + **RESOLVED (2026-07-12, David's call): option C applied.** C3 no longer does a before/after + count diff; it captures the idx the valid probe used and verifies **that specific ghost + (`CTLD_MVP_H`) exists by name** β€” robust to the ghosts the init pre-creates. ProbeOffMap + now PASS 4/4 (run-once guard kept, it protects C1 across re-runs). No FullGas decision needed. + ProbeLifeCheck (also ticket 02) passes β€” it checks the ghost's `life`, not a count diff. + - NB (David's note, worth exploring separately): `Unit.getDescByName(type).life > 0` detects an + installed **vehicle/unit** type with **no spawn at all** β€” a clean replacement for the + spawn-and-check on the ground/vehicle probe categories. It does NOT discriminate statics/ + heliports (returns a populated table even for a bogus type), so the heliport ghost-probe + still needs B or C β€” but the unit/vehicle probe could drop the ghost approach entirely. diff --git a/src/CTLD_crate.lua b/src/CTLD_crate.lua index df4b488..c5f0148 100644 --- a/src/CTLD_crate.lua +++ b/src/CTLD_crate.lua @@ -2682,6 +2682,17 @@ function CTLDCrateManager:refreshRequestEquipmentSection(playerObj) local showSets = ctld.gs("enableAllCrates") ~= false local processed = self._processedCrates or {} + -- Feature Q: pre-compute loadable whole-vehicle types for this transport (nil when not + -- capable). Restored from b1ddfe4 β€” commit 0a15814 (DCS-cargo fix) dropped this list and + -- hardcoded spawnAsVehicle=false, silently disabling whole-vehicle spawn from Request + -- Equipment (regression confirmed by FullGas; scenario_fq_vehicle_whole_transport F-Q-5). + local loadableList = nil + if caps.canTransportWholeVehicle then + loadableList = (playerObj.coalition == coalition.side.RED) + and caps.loadableVehiclesRED + or caps.loadableVehiclesBLUE + end + -- Shared spawn callback: handles both single crate (arg.unit) and set (arg.multiple). local function spawnFn(arg) local t = Unit.getByName(arg.unitName) @@ -2749,9 +2760,13 @@ function CTLDCrateManager:refreshRequestEquipmentSection(playerObj) local sc = entry.singleCrate local sideOk = (sc.side == nil) or (sc.side == playerObj.coalition) if sideOk and (not _crateIsJTAC(sc) or jtacOk) then - -- Request Equipment always spawns crates, never a whole vehicle. - -- Feature Q whole-vehicle spawn is handled by the dedicated "Load Vehicle" menu. + -- Feature Q: detect if this item should spawn a whole vehicle local spawnAsVehicle = false + if loadableList then + for _, ltype in ipairs(loadableList) do + if ltype == sc.unit then spawnAsVehicle = true; break end + end + end crateOrder = crateOrder + 1 menu:addCommand({ root, spawnSub, lgzName, category }, sc.desc, spawnFn, diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index b023931..6a641e2 100644 --- a/src/CTLD_i18n_en.lua +++ b/src/CTLD_i18n_en.lua @@ -467,7 +467,7 @@ ctld.i18n["en"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["en"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored" ctld.i18n["en"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T" ctld.i18n["en"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP" -ctld.i18n["en"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled" +ctld.i18n["en"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table" ctld.i18n["en"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled" ctld.i18n["en"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups" ctld.i18n["en"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped" diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 34803c0..6333f02 100644 --- a/src/CTLD_i18n_es.lua +++ b/src/CTLD_i18n_es.lua @@ -464,7 +464,7 @@ ctld.i18n["es"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["es"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] ERROR '%2': cargoType '%3' requiere transporte de vehΓ­culo completo pero ninguna aeronave tiene canTransportWholeVehicle=true β€” entrada ignorada" ctld.i18n["es"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] AVISO '%2': cargoType '%3' invΓ‘lido β€” predeterminado a T" ctld.i18n["es"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] AVISO '%2': aiDropMode '%3' invΓ‘lido β€” predeterminado a GP" -ctld.i18n["es"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] AVISO '%2': isPickup=true con carga de tropas pero troopStock no definido β€” carga de tropas desactivada" +ctld.i18n["es"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] AVISO '%2': isPickup=true con carga de tropas pero troopStock nil/invΓ‘lido β€” usar una tabla {[nombreTemplate]=N}" ctld.i18n["es"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] AVISO '%2': isPickup=true con carga de vehΓ­culo pero vehicleStock no definido β€” carga de vehΓ­culo desactivada" ctld.i18n["es"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] AVISO '%2': troopTemplates['%3'] no encontrado en loadableGroups" ctld.i18n["es"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] AVISO '%2': todos los troopTemplates son desconocidos β€” el pickup de tropas siempre se omitirΓ‘" diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index 6859f1b..41cd192 100644 --- a/src/CTLD_i18n_fr.lua +++ b/src/CTLD_i18n_fr.lua @@ -463,7 +463,7 @@ ctld.i18n["fr"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["fr"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] ERREUR '%2' : cargoType '%3' nΓ©cessite un transport de vΓ©hicule entier mais aucun aΓ©ronef n'a canTransportWholeVehicle=true β€” entrΓ©e ignorΓ©e" ctld.i18n["fr"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] AVERT '%2' : cargoType '%3' invalide β€” valeur par dΓ©faut T" ctld.i18n["fr"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] AVERT '%2' : aiDropMode '%3' invalide β€” valeur par dΓ©faut GP" -ctld.i18n["fr"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] AVERT '%2' : isPickup=true avec cargo de troupes mais troopStock non dΓ©fini β€” pickup de troupes dΓ©sactivΓ©" +ctld.i18n["fr"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] AVERT '%2' : isPickup=true avec cargo de troupes mais troopStock nil/invalide β€” utiliser une table {[nomTemplate]=N}" ctld.i18n["fr"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] AVERT '%2' : isPickup=true avec cargo de vΓ©hicule mais vehicleStock non dΓ©fini β€” pickup de vΓ©hicule dΓ©sactivΓ©" ctld.i18n["fr"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] AVERT '%2' : troopTemplates['%3'] introuvable dans loadableGroups" ctld.i18n["fr"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] AVERT '%2' : tous les troopTemplates sont inconnus β€” le pickup de troupes sera toujours ignorΓ©" diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 5d48a99..a8fbd2b 100644 --- a/src/CTLD_i18n_ko.lua +++ b/src/CTLD_i18n_ko.lua @@ -314,7 +314,7 @@ ctld.i18n["ko"][" AIZ[%1] ERROR '%2': neither isPickup nor isDropoff β€” zone d ctld.i18n["ko"][" AIZ[%1] ERROR '%2': cargoType '%3' requires whole-vehicle transport but no aircraft has canTransportWholeVehicle=true β€” entry ignored"] = " AIZ[%1] 였λ₯˜ '%2': cargoType '%3'은(λŠ”) μ°¨λŸ‰ 전체 μˆ˜μ†‘μ΄ ν•„μš”ν•˜μ§€λ§Œ canTransportWholeVehicle=true인 항곡기가 μ—†μŒ β€” ν•­λͺ© λ¬΄μ‹œλ¨" ctld.i18n["ko"][" AIZ[%1] WARN '%2': invalid cargoType '%3' β€” defaulting to T"] = " AIZ[%1] κ²½κ³  '%2': cargoType '%3' 잘λͺ»λ¨ β€” κΈ°λ³Έκ°’ T" ctld.i18n["ko"][" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP"] = " AIZ[%1] κ²½κ³  '%2': aiDropMode '%3' 잘λͺ»λ¨ β€” κΈ°λ³Έκ°’ GP" -ctld.i18n["ko"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled"] = " AIZ[%1] κ²½κ³  '%2': isPickup=true이고 병λ ₯ ν™”λ¬Όμ΄μ§€λ§Œ troopStock λ―Έμ •μ˜ β€” 병λ ₯ ν”½μ—… λΉ„ν™œμ„±ν™”" +ctld.i18n["ko"][" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table"] = " AIZ[%1] κ²½κ³  '%2': isPickup=true이고 병λ ₯ ν™”λ¬Όμ΄μ§€λ§Œ troopStock nil/무효 β€” {[templateName]=N} ν…Œμ΄λΈ” μ‚¬μš©" ctld.i18n["ko"][" AIZ[%1] WARN '%2': isPickup=true with vehicle cargo but vehicleStock not defined β€” vehicle pickup disabled"] = " AIZ[%1] κ²½κ³  '%2': isPickup=true이고 μ°¨λŸ‰ ν™”λ¬Όμ΄μ§€λ§Œ vehicleStock λ―Έμ •μ˜ β€” μ°¨λŸ‰ ν”½μ—… λΉ„ν™œμ„±ν™”" ctld.i18n["ko"][" AIZ[%1] WARN '%2': troopTemplates['%3'] not found in loadableGroups"] = " AIZ[%1] κ²½κ³  '%2': troopTemplates['%3']을(λ₯Ό) loadableGroupsμ—μ„œ 찾을 수 μ—†μŒ" ctld.i18n["ko"][" AIZ[%1] WARN '%2': all troopTemplates are unknown β€” troop pickup will always be skipped"] = " AIZ[%1] κ²½κ³  '%2': λͺ¨λ“  troopTemplatesλ₯Ό μ•Œ 수 μ—†μŒ β€” 병λ ₯ 픽업이 항상 κ±΄λ„ˆλœ€" diff --git a/src/CTLD_zone.lua b/src/CTLD_zone.lua index 297b32b..d4f8f58 100644 --- a/src/CTLD_zone.lua +++ b/src/CTLD_zone.lua @@ -680,7 +680,9 @@ function CTLDZoneManager:_loadAIZonesFromConfig() isAIPickup = entry.isPickup == true, isAIDropoff = entry.isDropoff == true, aiCargoType = entry.cargoType or "T", - pickMaxStock = 0, -- unlimited; per-template stock via _aiTroopStock + -- pickup zones: 0 = unlimited (per-template stock lives in _aiTroopStock); + -- dropoff-only zones: nil, so hasPickup() stays false (FullGas fix, F-R-2/13.8). + pickMaxStock = entry.isPickup and 0 or nil, troopTemplates = troopTemplates, vehicleTypes = (entry.vehicleTypes and #entry.vehicleTypes > 0) and entry.vehicleTypes or nil, @@ -1457,10 +1459,14 @@ function CTLDZoneManager:_validateZoneNames() if entry.aiDropMode and not VALID_DROP_MODE[entry.aiDropMode] then warns[#warns + 1] = ctld.tr(" AIZ[%1] WARN '%2': invalid aiDropMode '%3' β€” defaulting to GP", i, tostring(dzn), tostring(entry.aiDropMode)) end - -- G3: isPickup + troop cargo + troopStock not defined β†’ troop pickup disabled + -- G3: isPickup + troop cargo + troopStock nil OR not a {[name]=N} table (incl. a legacy + -- scalar like 0/-1/10, or an empty table) β†’ invalid format, troop pickup disabled. local effCargoHasTroops = (not entry.cargoType or entry.cargoType == "T" or entry.cargoType == "TV") - if not hasErr and entry.isPickup and effCargoHasTroops and entry.troopStock == nil then - warns[#warns + 1] = ctld.tr(" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock not defined β€” troop pickup disabled", i, tostring(dzn)) + local troopStockInvalid = entry.troopStock == nil + or type(entry.troopStock) ~= "table" + or next(entry.troopStock) == nil + if not hasErr and entry.isPickup and effCargoHasTroops and troopStockInvalid then + warns[#warns + 1] = ctld.tr(" AIZ[%1] WARN '%2': isPickup=true with troop cargo but troopStock nil/invalid β€” use a {[templateName]=N} table", i, tostring(dzn)) end -- G6: isPickup + vehicle cargo + vehicleStock not defined β†’ vehicle pickup disabled local effCargoHasVehicle = (entry.cargoType == "V" or entry.cargoType == "TV") diff --git a/tests/dcs/noPlayer/F-117_reconDisabledScanShowsExplicitMessageNotSilent.lua b/tests/dcs/noPlayer/F-117_reconDisabledScanShowsExplicitMessageNotSilent.lua index d571275..ea49891 100644 --- a/tests/dcs/noPlayer/F-117_reconDisabledScanShowsExplicitMessageNotSilent.lua +++ b/tests/dcs/noPlayer/F-117_reconDisabledScanShowsExplicitMessageNotSilent.lua @@ -1,6 +1,6 @@ -- @tier: auto -- F-117 β€” RECON disabled: scan() shows explicit message (not silent) --- Verify: when reconEnabled=false, scan() emits outTextForGroup with "reconEnabled" keyword. +-- Verify: when reconF10Menu=false, scan() emits outTextForGroup with "reconF10Menu" keyword. local results = {} local function assert_eq(label, got, exp) @@ -15,10 +15,20 @@ local function assert_true(label, cond) end local rmgr = CTLDReconManager.getInstance() -local players = coalition.getPlayers(coalition.side.BLUE) or {} -local pu = players[1] + +-- scan() takes a UNIT, not a live client β€” no human player needed here. Spawn a throwaway +-- ground observer resolvable via Unit.getByName (a pure mock would not be found by _scanLOS). +local OBS_GRP = "F117_recon_obs_grp" +local OBS = "F117_recon_obs" +local _spawn = ctld.utils.dynAdd("F-117:observer", { + category = Group.Category.GROUND, + country = country.id.USA, + name = OBS_GRP, + units = { { type = "Soldier M4", name = OBS, x = -356482, y = 616908, heading = 0, skill = "Average" } }, +}) +local pu = _spawn and Group.getByName(_spawn.name) and Group.getByName(_spawn.name):getUnit(1) or nil if not pu then - _SCN_F117_RESULT = "[F-117] ABORT: no BLUE player" + _SCN_F117_RESULT = "[F-117] ABORT: observer spawn failed" return _SCN_F117_RESULT end local playerName = pu:getName() @@ -35,25 +45,29 @@ trigger.action.outTextForGroup = function(gid, msg, dur) _orig_otfg(gid, msg, dur) end --- ── U-01: reconEnabled=false β†’ message contains "reconEnabled" ─────────────── -local _origEnabled = cfg["reconEnabled"] -cfg["reconEnabled"] = false +-- ── U-01: reconF10Menu=false β†’ message contains "reconF10Menu" ─────────────── +local _origEnabled = cfg["reconF10Menu"] +cfg["reconF10Menu"] = false pcall(function() rmgr:scan(pu, playerName) end) assert_true("U-01 message emitted", _captured ~= nil) -assert_true("U-01 message mentions config", _captured and _captured:find("reconEnabled")) +assert_true("U-01 message mentions config", _captured and _captured:find("reconF10Menu")) --- ── U-02: reconEnabled=true β†’ no disabled message ─────────────────────────── +-- ── U-02: reconF10Menu=true β†’ no disabled message ─────────────────────────── _captured = nil -cfg["reconEnabled"] = true -cfg["reconMinAltitude"] = 0 -- bypass altitude check +cfg["reconF10Menu"] = true +local _origMinAlt = cfg["reconMinAltitude"] +cfg["reconMinAltitude"] = 0 -- bypass altitude check (ground observer) pcall(function() rmgr:scan(pu, playerName) end) -- Message may be nil or be the "no layers" message β€” must NOT be the disabled message. -local isDisabledMsg = _captured and _captured:find("reconEnabled") +local isDisabledMsg = _captured and _captured:find("reconF10Menu") assert_eq("U-02 no disabled message when enabled", isDisabledMsg, nil) -- ── restore ─────────────────────────────────────────────────────────────────── -cfg["reconEnabled"] = _origEnabled +cfg["reconF10Menu"] = _origEnabled +cfg["reconMinAltitude"] = _origMinAlt trigger.action.outTextForGroup = _orig_otfg +local _obsGrp = Group.getByName(OBS_GRP) +if _obsGrp then pcall(function() _obsGrp:destroy() end) end local pass = 0; local fail = 0 local failReasons = {} diff --git a/tests/dcs/noPlayer/F-118_reconToggleOFFMarksRemovedImmediatelyEvenWhenNoLay.lua b/tests/dcs/noPlayer/F-118_reconToggleOFFMarksRemovedImmediatelyEvenWhenNoLay.lua index 741427b..d3ca156 100644 --- a/tests/dcs/noPlayer/F-118_reconToggleOFFMarksRemovedImmediatelyEvenWhenNoLay.lua +++ b/tests/dcs/noPlayer/F-118_reconToggleOFFMarksRemovedImmediatelyEvenWhenNoLay.lua @@ -1,7 +1,9 @@ -- @tier: auto -- F-118 β€” RECON toggle-OFF: marks removed immediately even when no layers remain. --- Verify: when scan() is called and prevScan exists, marks are removed BEFORE the --- "no layers" early-return path β€” so toggling the last layer OFF clears the map instantly. +-- Verify: when scan() is called and prevScan exists, the previous marks are removed and a +-- fresh (empty) scan replaces it. Post-redesign scan() has NO early-return: _activeScans[player] +-- is ALWAYS set, so toggling the last layer OFF clears the old marks but keeps RECON active +-- with zero targets (player can re-enable layers via menu without restarting RECON). local results = {} local function assert_eq(label, got, exp) @@ -15,10 +17,20 @@ local function assert_true(label, cond) assert_eq(label, not not cond, true) e local function assert_false(label, cond) assert_eq(label, not not cond, false) end local rmgr = CTLDReconManager.getInstance() -local players = coalition.getPlayers(coalition.side.BLUE) or {} -local pu = players[1] + +-- scan() takes a UNIT, not a live client β€” no human player needed. Spawn a throwaway ground +-- observer resolvable via Unit.getByName (a pure mock would not be found by _scanLOS). +local OBS_GRP = "F118_recon_obs_grp" +local OBS = "F118_recon_obs" +local _spawn = ctld.utils.dynAdd("F-118:observer", { + category = Group.Category.GROUND, + country = country.id.USA, + name = OBS_GRP, + units = { { type = "Soldier M4", name = OBS, x = -356482, y = 616908, heading = 0, skill = "Average" } }, +}) +local pu = _spawn and Group.getByName(_spawn.name) and Group.getByName(_spawn.name):getUnit(1) or nil if not pu then - _SCN_F118_RESULT = "[F-118] ABORT: no BLUE player" + _SCN_F118_RESULT = "[F-118] ABORT: observer spawn failed" return _SCN_F118_RESULT end local playerName = pu:getName() @@ -61,7 +73,7 @@ rmgr._activeScans[playerName] = fakeScan assert_true("U-01 fake scan injected", rmgr._activeScans[playerName] ~= nil) assert_eq("U-01 fake scan has 1 target", #rmgr._activeScans[playerName].targets, 1) --- ── U-02: disable ALL layers so scan() would hit "no layers" early-return ───── +-- ── U-02: disable ALL layers β†’ new scan yields zero targets (no early-return) ─ local allLayers = rmgr:_getPlayerLayers(playerName) local savedEnabled = {} for _, l in ipairs(allLayers) do @@ -73,14 +85,15 @@ end local ok, err = pcall(function() rmgr:scan(pu, playerName) end) assert_true("U-03 scan() no error", ok) --- After scan(): _activeScans[playerName] must be nil (prevScan was cleaned up). -assert_eq("U-04 activeScans nil after all-OFF scan", rmgr._activeScans[playerName], nil) +-- After scan(): post-redesign there is NO early-return β€” _activeScans[playerName] stays SET, +-- but the previous marks are removed and the fresh scan holds zero targets (all layers OFF). +assert_true("U-04 activeScans still active after all-OFF scan (no early-return)", + rmgr._activeScans[playerName] ~= nil) --- ── U-05: the fake mark slot must be gone (removeMark was called) ──────────── --- We can't directly query DCS for mark existence, but we verify the target's markId --- was part of the scan that got cleaned β€” indirect proof via _activeScans being nil. --- (Direct visual check: orange "F118" circle must disappear immediately on F10 map.) -assert_true("U-05 cleanup occurred (scan entry nil)", rmgr._activeScans[playerName] == nil) +-- ── U-05: the fresh scan replaced the old one with ZERO targets (old marks cleaned) ── +-- (Direct visual check: orange "F118" circle disappears immediately on F10 map.) +assert_eq("U-05 fresh scan has zero targets (old marks removed)", + rmgr._activeScans[playerName] and #rmgr._activeScans[playerName].targets or -1, 0) -- ── restore ─────────────────────────────────────────────────────────────────── for _, l in ipairs(allLayers) do @@ -89,6 +102,9 @@ end cfg["reconEnabled"] = _origEnabled cfg["reconMinAltitude"] = _origMinAlt cfg["reconSearchRadius"] = _origRadius +rmgr._activeScans[playerName] = nil +local _obsGrp = Group.getByName(OBS_GRP) +if _obsGrp then pcall(function() _obsGrp:destroy() end) end local pass = 0; local fail = 0 local failReasons = {} diff --git a/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeLifeCheck.lua b/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeLifeCheck.lua index 2b28b35..a12a2dc 100644 --- a/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeLifeCheck.lua +++ b/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeLifeCheck.lua @@ -8,6 +8,16 @@ -- C4: cache hit invalid β†’ no new probe -- Note: ghost spawnΓ© 800km est, hors zone visible β€” vΓ©rifier F10 si souhaitΓ© +-- Run-once guard: the valid probe spawns a real DCS ghost airbase that cannot be +-- destroyed (DCS limitation). Re-running in the same session would accumulate ghosts, +-- so short-circuit with an explicit PASS after the first run. +if _U108_LIFECHECK_DONE then + _SCN_U108_RESULT = "[U-108] PASS (run-once: already probed this session)" + trigger.action.outText("[U-108] run-once guard: already probed this session", 10) + return _SCN_U108_RESULT +end +_U108_LIFECHECK_DONE = true + local _t = { pass=0, fail=0, msgs={} } local function chk(label, cond) if cond then _t.pass=_t.pass+1; _t.msgs[#_t.msgs+1]="[PASS] "..label diff --git a/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeOffMap.lua b/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeOffMap.lua index f264e53..497c1eb 100644 --- a/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeOffMap.lua +++ b/tests/dcs/noPlayer/U-108_modValidatorHeliportProbeOffMap.lua @@ -9,6 +9,15 @@ -- Requires: DCS active, CTLD injected -- User check: verify F10 map β€” no FARP marker visible near Batumi / play area +-- Run-once guard: the valid probe spawns a real DCS ghost airbase that cannot be +-- destroyed (DCS limitation). Re-running in the same session would accumulate ghosts +-- and break C3's diff assertion, so short-circuit with an explicit PASS after first run. +if _U108_OFFMAP_DONE then + trigger.action.outText("[U-108] run-once guard: already probed this session", 10) + return "[U-108] PASS (run-once: already probed this session)" +end +_U108_OFFMAP_DONE = true + local _t = { pass=0, fail=0, msgs={} } local function chk(label, cond) if cond then @@ -36,10 +45,9 @@ mv._probePos = { x = -356437, z = 617000 } -- near Batumi local _origLog = ctld.utils.log ctld.utils.log = function() end -local before = countProbeAirbases() - -- C1: valid HELIPORT type (SINGLE_HELIPAD β€” standard DCS helipad) mv:_probeHeliport("SINGLE_HELIPAD", "Heliports", {}) +local c1Idx = mv._probeIdx -- idx used by the valid probe β†’ ghost name "CTLD_MVP_H" .. c1Idx chk("C1 valid HELIPORT (SINGLE_HELIPAD) β†’ cache=true", mv._cache["S:SINGLE_HELIPAD"] == true) -- C2: invalid HELIPORT type (fabricated name) @@ -48,9 +56,13 @@ chk("C2 invalid HELIPORT β†’ cache=false", mv._cache["S:CTLD_INVALID_HELIPAD_XYZ local idx_after_probes = mv._probeIdx --- C3: ghost(s) from valid probe exist in registry but only 1 was added (invalid β†’ no spawn) -local after = countProbeAirbases() -chk("C3 exactly 1 ghost probe airbase added (off-map)", after - before == 1) +-- C3 (option C): the valid probe created its OWN off-map ghost β€” verify it EXISTS by name. +-- Robust to the ghosts CTLD init pre-creates at load (the old before/after count-diff collided +-- with them and failed even on the first run). We check the specific ghost this probe spawned. +local after = countProbeAirbases() +local c1Ghost = "CTLD_MVP_H" .. c1Idx +local c1Exists = StaticObject.getByName(c1Ghost) ~= nil or Airbase.getByName(c1Ghost) ~= nil +chk("C3 valid probe created off-map ghost " .. c1Ghost, c1Exists) -- C4: cache hit valid β†’ no new probe, count unchanged local r4 = mv:_probeHeliport("SINGLE_HELIPAD", "Heliports", {}) diff --git a/tests/dcs/noPlayer/U-108_modValidatorHeliportWarnAndSkip.lua b/tests/dcs/noPlayer/U-108_modValidatorHeliportWarnAndSkip.lua index d57970b..d2207b7 100644 --- a/tests/dcs/noPlayer/U-108_modValidatorHeliportWarnAndSkip.lua +++ b/tests/dcs/noPlayer/U-108_modValidatorHeliportWarnAndSkip.lua @@ -1,12 +1,16 @@ ---@diagnostic disable -- @tier: auto --- U-108: CTLDModValidator β€” Heliport types: WARN per type and skip (cannot auto-probe) --- DCS accepts any Heliport type name regardless of mod installation β†’ auto-probe unreliable. +-- U-108: CTLDModValidator β€” Heliport probe: stock types probed, custom-mod (probeSkip) skipped +-- DCS scripting API cannot distinguish an installed custom-mod heliport from a missing type +-- (getDesc().life == 0 for both) β†’ such registry entries carry probeSkip=true and are skipped +-- from probing to avoid a false NOT FOUND alarm. Stock heliport types (FARP, SINGLE_HELIPAD, +-- Invisible FARP) have no probeSkip and are collected as HELIPORT probeType entries. -- Checks: --- C1: _collectTypeNames() returns no HELIPORT probeType entries --- C2: isStaticInvalid("SINGLE_HELIPAD") β†’ false (type not probed, not in cache as invalid) --- C3: a WARN log is emitted for each Heliport type found in registry --- C4: other probe types (GROUND/STATIC) still function normally +-- C1 : probeSkip heliport (Farp_FG_Petit_Helipad) is ABSENT from HELIPORT probeType entries +-- C1b : the 3 stock heliport types are PRESENT as HELIPORT probeType entries +-- C2 : isStaticInvalid("SINGLE_HELIPAD") β†’ false (not probed in this scenario, not cached invalid) +-- C3 : an INFO "skipped" log (mentioning probeSkip) is emitted, and no spurious WARN +-- C4 : other probe types (GROUND) still function normally (BRDM-2 β†’ true) -- Requires: DCS active, CTLD injected local _t = { pass=0, fail=0, msgs={} } @@ -18,12 +22,16 @@ local function chk(label, cond) end end --- Capture log calls -local logWarns = {} +-- Capture log calls (INFO and WARN separately) +local logInfos, logWarns = {}, {} local _origLog = ctld.utils.log ctld.utils.log = function(level, fmt, ...) + local ok, s = pcall(string.format, fmt, ...) + s = ok and s or tostring(fmt) if level == "WARN" then - logWarns[#logWarns+1] = string.format(fmt, ...) + logWarns[#logWarns+1] = s + elseif level == "INFO" then + logInfos[#logInfos+1] = s end end @@ -32,28 +40,36 @@ CTLDModValidator._instance = nil local mv = CTLDModValidator.getInstance() mv._probePos = { x = -356437, z = 617000 } --- C1: _collectTypeNames yields no HELIPORT entries +-- Collect once; classify HELIPORT entries by typeName local entries = mv:_collectTypeNames() -local heliportCount = 0 +local heliportTypes = {} for _, e in ipairs(entries) do - if e.probeType == "HELIPORT" then heliportCount = heliportCount + 1 end + if e.probeType == "HELIPORT" then heliportTypes[e.typeName] = true end end -chk("C1 no HELIPORT probeType entries", heliportCount == 0) --- C2: isStaticInvalid for a known Heliport type β†’ false (not probed, not cached as invalid) +-- C1: the probeSkip=true heliport is excluded from HELIPORT probe entries +chk("C1 probeSkip heliport 'Farp_FG_Petit_Helipad' absent from HELIPORT entries", + heliportTypes["Farp_FG_Petit_Helipad"] == nil) + +-- C1b: the 3 stock heliport types are collected as HELIPORT (typeName = desc.type) +chk("C1b stock 'FARP' present as HELIPORT", heliportTypes["FARP"] == true) +chk("C1b stock 'SINGLE_HELIPAD' present as HELIPORT", heliportTypes["SINGLE_HELIPAD"] == true) +chk("C1b stock 'Invisible FARP' present as HELIPORT", heliportTypes["Invisible FARP"] == true) + +-- C2: isStaticInvalid for a known Heliport type β†’ false (not probed here, not cached as invalid) -- (cache["S:SINGLE_HELIPAD"] is nil β†’ isStaticInvalid returns nil==false β†’ false) chk("C2 isStaticInvalid(SINGLE_HELIPAD) β†’ false", mv:isStaticInvalid("SINGLE_HELIPAD") == false) --- C3: at least one WARN emitted containing "cannot auto-validate" and "Heliport" --- (only fires if registry has Heliport types β€” standard config has FARP / SINGLE_HELIPAD entries) -local warnOk = false -for _, w in ipairs(logWarns) do - if w:find("cannot auto%-validate") and w:find("Heliport") then - warnOk = true; break +-- C3: an INFO "skipped" log mentioning probeSkip is emitted for the custom-mod heliport, +-- and _collectTypeNames raised no spurious WARN. +local skipInfoOk = false +for _, m in ipairs(logInfos) do + if m:find("skipped") and m:find("probeSkip") then + skipInfoOk = true; break end end -chk("C3 WARN emitted for Heliport type with 'cannot auto-validate'", - warnOk or #logWarns == 0) -- pass also if registry has no Heliport types at all +chk("C3 INFO 'skipped' (probeSkip) emitted for custom-mod heliport", skipInfoOk) +chk("C3 no spurious WARN during _collectTypeNames", #logWarns == 0) -- C4: other probe types still work (sanity: GROUND probe on known valid type) ctld.utils.log = _origLog -- restore before probeGround (needs log) @@ -63,8 +79,8 @@ local r4 = mv:_probeGround("BRDM-2") ctld.utils.log = _origLog2 chk("C4 GROUND probe still works (BRDM-2 β†’ true)", r4 == true) -local msg = string.format("U-108 HELIPORT warn+skip: %d PASS / %d FAIL\n%s\nWarns captured=%d", - _t.pass, _t.fail, table.concat(_t.msgs, "\n"), #logWarns) +local msg = string.format("U-108 HELIPORT probe/skip: %d PASS / %d FAIL\n%s\nInfos=%d Warns=%d", + _t.pass, _t.fail, table.concat(_t.msgs, "\n"), #logInfos, #logWarns) trigger.action.outText(msg, 35) env.info("[U-108] " .. msg:gsub("\n", " | ")) -- dcs-bridge return contract (sync verdict; C1..C4 programmatic) diff --git a/tests/dcs/noPlayer/scenario_farp_countryside_spawn.lua b/tests/dcs/noPlayer/scenario_farp_countryside_spawn.lua index aa55644..878e40e 100644 --- a/tests/dcs/noPlayer/scenario_farp_countryside_spawn.lua +++ b/tests/dcs/noPlayer/scenario_farp_countryside_spawn.lua @@ -57,17 +57,11 @@ end log("=== START: "..NAME.." ===") local ok, err = pcall(function() - local anchor = StaticObject.getByName("coord_farp-1") - if not anchor or not anchor:isExist() then - trigger.action.outText(TAG.." [FAIL] static 'coord_farp-1' not found", 15) - log("[FAIL] static 'coord_farp-1' not found") - _SCN_CSFARPSPAWN_RESULT = TAG.." FAIL: static 'coord_farp-1' not found" - return - end - - local pos = anchor:getPoint() + -- coord_farp-1 anchor hardcoded: the static is absent from the VEAF test mission. + -- Map coords provided by David (X/Z in metres, ~6.4 m β‰ˆ 21 ft). + local pos = { x = -356482, y = 6.4, z = 616908 } local coalitionId = coalition.side.BLUE - local countryId = anchor:getCountry() + local countryId = country.id.USA log(string.format("anchor pos=(%.1f, %.1f, %.1f) coa=%d cty=%d", pos.x, pos.y, pos.z, coalitionId, countryId)) diff --git a/tests/dcs/noPlayer/scenario_farp_metal_spawn.lua b/tests/dcs/noPlayer/scenario_farp_metal_spawn.lua index 765b21c..1c888fa 100644 --- a/tests/dcs/noPlayer/scenario_farp_metal_spawn.lua +++ b/tests/dcs/noPlayer/scenario_farp_metal_spawn.lua @@ -58,17 +58,11 @@ end log("=== START: "..NAME.." ===") local ok, err = pcall(function() - local anchor = StaticObject.getByName("coord_farp-1") - if not anchor or not anchor:isExist() then - trigger.action.outText(TAG.." [FAIL] static 'coord_farp-1' not found", 15) - log("[FAIL] static 'coord_farp-1' not found") - _SCN_METALFARPSPAWN_RESULT = TAG.." FAIL: static 'coord_farp-1' not found" - return - end - - local pos = anchor:getPoint() - local coalitionId = anchor:getCoalition() - local countryId = anchor:getCountry() + -- coord_farp-1 anchor hardcoded: the static is absent from the VEAF test mission. + -- Map coords provided by David (X/Z in metres, ~6.4 m β‰ˆ 21 ft). + local pos = { x = -356482, y = 6.4, z = 616908 } + local coalitionId = coalition.side.BLUE + local countryId = country.id.USA log(string.format("anchor pos=(%.1f, %.1f, %.1f) coa=%d cty=%d", pos.x, pos.y, pos.z, coalitionId, countryId)) diff --git a/tests/dcs/noPlayer/scenario_fr_ai_zones.lua b/tests/dcs/noPlayer/scenario_fr_ai_zones.lua index 8c9dd01..96e45b6 100644 --- a/tests/dcs/noPlayer/scenario_fr_ai_zones.lua +++ b/tests/dcs/noPlayer/scenario_fr_ai_zones.lua @@ -176,16 +176,21 @@ end -- Section 1 β€” Feature S: _loadAIZonesFromConfig -- ══════════════════════════════════════════════════════════════════════════════ --- F-R-1: pickup T stock=10 β†’ isAIPickup=true, pickMaxStock=10, coalition=BLUE +-- F-R-1: pickup, per-template troopStock table β†’ isAIPickup=true, pickMaxStock=0 (unlimited +-- guard β€” real per-template stock lives in _aiTroopStock), coalition=BLUE. +-- troopStock is now a {[templateName]=N} table, not a scalar (FullGas redesign). NB: the "All" +-- key only flags isAll (its value is ignored) β€” a *limited* stock must use a named template. withAIZ( - { dcsZoneName="ai_fr1", coalition="BLUE", isPickup=true, cargoType="T", troopStock=10 }, + { dcsZoneName="ai_fr1", coalition="BLUE", isPickup=true, cargoType="T", + troopStock={ ["Standard Group"]=10 } }, function(z) checkNotNil("F-R-1.1", z) if z then checkTrue ("F-R-1.2", z.isAIPickup) checkFalse("F-R-1.3", z.isAIDropoff) - check ("F-R-1.4", z.pickMaxStock, 10) + check ("F-R-1.4", z.pickMaxStock, 0) check ("F-R-1.5", z.coalition, coalition.side.BLUE) + check ("F-R-1.6", z._aiTroopStock and z._aiTroopStock.init["Standard Group"], 10) end end) @@ -209,12 +214,17 @@ withAIZ( if z then check("F-R-3.2", z.pickMaxStock, 0) end end) --- F-R-4: pickup troopStock=0 β†’ pickMaxStock=nil (disabled) +-- F-R-4: legacy scalar troopStock=0 β†’ invalid format. pickMaxStock=0 (it IS a pickup zone), +-- _aiTroopStock=nil (scalar rejected by parseStockTable). The old "0 = disabled" semantics is +-- gone β€” an explicit G3 WARN is emitted instead (covered by F-R-43). FullGas redesign. withAIZ( { dcsZoneName="ai_fr4", coalition="BLUE", isPickup=true, cargoType="T", troopStock=0 }, function(z) checkNotNil("F-R-4.1", z) - if z then checkNil("F-R-4.2", z.pickMaxStock) end + if z then + check ("F-R-4.2", z.pickMaxStock, 0) + checkNil("F-R-4.3", z._aiTroopStock) + end end) -- F-R-5: missing coalition β†’ zone not created (error pre-set by _validateZoneNames) @@ -343,7 +353,8 @@ local _savedGetZ13 = trigger.misc.getZone local _savedTZ13 = zm._troopZones cfg.settings["aiZones"] = { - { dcsZoneName="test_ai_p", coalition="BLUE", isPickup=true, cargoType="T", troopStock=5 }, + { dcsZoneName="test_ai_p", coalition="BLUE", isPickup=true, cargoType="T", + troopStock={ ["Standard Group"]=5 } }, { dcsZoneName="test_ai_d", coalition="BLUE", isDropoff=true }, } trigger.misc.getZone = function(name) @@ -362,7 +373,8 @@ checkNotNil("F-R-13.2", zAID) if zAIP then checkTrue ("F-R-13.3", zAIP:hasAIPickup()) checkFalse("F-R-13.4", zAIP:hasAIDropoff()) - check ("F-R-13.5", zAIP.pickMaxStock, 5) + check ("F-R-13.5", zAIP.pickMaxStock, 0) -- pickup guard; real stock in _aiTroopStock + check ("F-R-13.5b", zAIP._aiTroopStock and zAIP._aiTroopStock.init["Standard Group"], 5) end if zAID then checkFalse("F-R-13.6", zAID:hasAIPickup()) @@ -934,7 +946,7 @@ do checkNotNil("F-R-43.2", zones[dzn]) -- zone IS created checkNotNil("F-R-43.3", report) -- WARN shown if report then - checkTrue("F-R-43.4", string.find(report, "troopStock=0", 1, true) ~= nil) + checkTrue("F-R-43.4", string.find(report, "troopStock nil/invalid", 1, true) ~= nil) end end @@ -1066,7 +1078,7 @@ do checkTrue("F-R-49.6", string.find(reportSnapshot, "bad_noME", 1, true) ~= nil) checkTrue("F-R-49.7", string.find(reportSnapshot, "bad_dup", 1, true) ~= nil) checkTrue("F-R-49.8", string.find(reportSnapshot, "bad_G5", 1, true) ~= nil) - checkTrue("F-R-49.9", string.find(reportSnapshot, "troopStock=0", 1, true) ~= nil) + checkTrue("F-R-49.9", string.find(reportSnapshot, "troopStock nil/invalid", 1, true) ~= nil) checkTrue("F-R-49.10", string.find(reportSnapshot, "all troopTemplates are unknown", 1, true) ~= nil) end