diff --git a/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/PRD.md b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/PRD.md new file mode 100644 index 00000000..1efb1e89 --- /dev/null +++ b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/PRD.md @@ -0,0 +1,170 @@ +Status: ready + +# FIX-FIELD-EXTRACT-CASUALTIES — field extraction ignores combat losses + +## Problem Statement + +A transport pilot who deploys a troop group, watches part of it die in combat, then lands nearby +to extract the survivors gets back the **original** headcount, not the survivor count. Ten +infantry dropped, three killed, seven left alive on the ground — re-embarking that group puts ten +troops back on the aircraft's manifest. + +This silently breaks capacity accounting (a "full" load may in fact be smaller than reported) and +misleads the pilot about what they actually recovered. It is also an undeclared deviation from the +legacy `CTLD.lua`, which has always counted the DCS group's live units at the moment of extraction. + +Separately, the "Extract from field" menu only ever showed distance to a nearby group, never its +size — so a pilot choosing between several dropped groups within extraction range has no way to +tell, before committing, which one will actually fit their remaining capacity. + +## Solution + +Field extraction (`embarkFromField`) now counts the group's **currently alive** DCS units at the +moment of extraction — mirroring the legacy behavior — instead of the headcount frozen at deploy +time. Cosmetic mortar-servant units (`SVNT_*`) stay excluded from that count, exactly as they are +today; only real troop-role units (including the mortar unit itself) are counted. + +The "Extract from field" F10 menu (both the single-group direct button and the multi-group +submenu) now shows each group's current troop count alongside its name (and distance, in the +submenu case), so the pilot can choose with full information. + +A group that has lost every real troop-role unit but still has a lone mortar servant standing +(mortar operator dead, servant alive) is no longer offered for extraction — there is nothing +useful to recover — and that residual servant is despawned automatically the instant this +degenerate state is reached, instead of lingering on the battlefield indefinitely. + +## User Stories + +1. As a transport pilot, I want the number of troops I recover when extracting a group from the + field to reflect the survivors only, so that my aircraft's manifest is accurate. +2. As a transport pilot, I want the cargo weight of a re-extracted group to scale with the actual + number of survivors, so that my weight/capacity accounting stays correct after casualties. +3. As a transport pilot with several dropped groups within extraction range, I want to see each + group's current troop count in the "Extract from field" submenu, so that I can choose the group + that best fits my remaining capacity before committing. +4. As a transport pilot with exactly one dropped group nearby, I want the direct "Extract: ..." + button to also show that group's current troop count, so that the single-group case gives me + the same information as the multi-group case. +5. As a transport pilot, I want a group reduced to zero real troops (only a leftover mortar + servant) to no longer appear as extractable, so that I am not offered a pointless pickup. +6. As a mission maker, I want an orphaned mortar servant (its mortar operator killed, servant + alive) to be cleaned up automatically, so that dead weight does not linger on the map for the + rest of the mission. +7. As a transport pilot, I want the mortar unit itself to always count as a real troop (only its + cosmetic servant excluded), so that a surviving mortar team is not undercounted. +8. As a transport pilot extracting a group that includes a JTAC, I want the troop count to include + the JTAC unchanged from today, so that this fix introduces no regression on JTAC-carrying + groups. +9. As a transport pilot loading fresh troops from a TRZ_ pickup zone, I want that count to remain + exactly as accurate as it is today, so that this fix — scoped to field extraction — introduces + no regression on the pickup-zone path. +10. As a transport pilot who extracts a group that took zero casualties, I want to recover the + exact number originally deployed, so that the common, undamaged case is unaffected. +11. As a transport pilot who returns a casualty-reduced group to a TRZ_ pickup zone afterward, I + want the zone's stock to be restored by the number of survivors I actually returned, not the + original deployed count, so that zone stock accounting stays consistent with what changed + hands. +12. As a transport pilot carrying several groups at once, I want "Check Cargo" to show the correct + survivor-based count and weight for a group I re-extracted after losses, so that my full + manifest stays accurate. +13. As a developer reading `CONTEXT.md`, I want "logical troop count" defined precisely and + distinguished from the raw DCS unit count, so that the servant-exclusion rule is documented in + one place instead of only living in code comments. +14. As a QA reviewer checking legacy parity, I want `src/`'s field-extraction count to match + `migration/source/CTLD.lua`'s live-unit-count behavior, so that this documented deviation is + closed. +15. As a translator maintaining the i18n dictionaries, I want the new/changed menu-label strings + picked up by the existing dict-sync tooling, so that I don't have to hand-add keys. + +## Implementation Decisions + +- **Shared logical-count helper**: factor the existing servant-exclusion rule (already implemented + in `CTLDTroopGroup:_syncFromDCSGroup` — count a live DCS group's units, excluding any named with + the `SVNT` prefix) into a helper usable against any live `Group` object. Three call sites need + it: the extraction count itself, the two extraction-menu labels, and the orphan-servant check in + `onUnitDead`. No new naming convention introduced — reuses the `SVNT` prefix rule as-is. + +- **`embarkFromField`**: replace the current `logicalCount = stored.total or groupSize` (which + prefers the count frozen at deploy time) with the live count from the helper, applied to + `nearest.group`. `stored.total` / `stored.weight` remain in `_droppedTemplates` but are now used + **only** to derive the original per-unit average weight (`stored.weight / stored.total`), which + still multiplies against the new, correct `logicalCount` to get proportional cargo weight — this + part of the formula is unchanged. + +- **Nearby-group lookups filter out zero-logical-count groups**: both the single-nearest lookup and + the multi-group nearby lookup exclude any dropped group whose live logical count is 0, so a + servant-only residue is never offered as an extraction target. + +- **Menu labels** (embark/extract submenu, built in `refreshMenuSection`): + - Single nearby group → `"Extract: %1 (%2 troops)"` (group name, logical count). + - Multiple nearby groups (submenu entries) → `"%1 (%2 troops, %3m)"` (group name, logical count, + distance in meters, floored as today). + - Both go through `ctld.tr(...)`, consistent with every other player-facing string in this file. + +- **`onUnitDead` reactive cleanup**: after removing the dead unit from a *deployed* (dropped, not + in-transit) group's live view, if the group's logical count is now 0 while the DCS group still + has units (i.e. only servants remain), destroy the residual DCS group immediately and purge it + from `_droppedGroups[coalition]` and `_droppedTemplates[groupName]` — the same purge already + performed by `_removeFromDropped`. No change to the JTAC-deregistration behavior already present + in `onUnitDead`. + +- **No config toggle.** This is a correctness fix (both the count and the servant cleanup), not a + new mission-maker-configurable behavior. + +- **No schema/state-machine change.** `CTLDTroopGroup.STATE` values and the troop state machine are + untouched; this only changes how `unitTotal` is computed at the `FIELD_LOADED` transition and + adds a reactive side effect in `onUnitDead`. + +- **`CONTEXT.md`**: "Logical troop count" glossary entry already added under Gameplay domain, + distinguishing it from the raw DCS unit count. + +## Testing Decisions + +Good tests here assert observable behavior — the count reported, the message/label text produced, +whether `destroy()` and the `_droppedGroups`/`_droppedTemplates` purge fired — not the existence or +internal shape of the shared helper itself. + +- **`tests/ci/functional/troop_manager_spec.lua`**, extending the existing `F-036 — + embarkFromField (extract)` block: mock `getUnits()` to return fewer live units than + `stored.total` (simulating casualties), including one `SVNT_`-prefixed mock unit among the + survivors, and assert the extracted logical count reflects the survivors with the servant + excluded — not `stored.total`. A companion case with zero casualties asserts the count still + equals the original deploy count (no regression on the common path). + +- **`tests/ci/functional/troop_manager_spec.lua`**, new `onUnitDead` describe block (no prior + coverage exists for this function): a dropped group where the last non-servant unit dies while + an `SVNT_` unit remains alive must trigger `group:destroy()` and removal from + `_droppedGroups`/`_droppedTemplates`. A case where a non-last real-troop unit dies must trigger + neither. + +- **`tests/ci/unit/menu_gating_spec.lua`**: extend the existing `_findAllNearbyDropped` stubbing + pattern used in this file with mock `.group` objects exposing `getUnits()`, and assert the + rendered command labels match the new formats for both the single- and multi-group cases, and + that a zero-logical-count entry is absent from what the lookup returns. + +- Prior art: `F-036` in `troop_manager_spec.lua` for the mocked-`Group.getByName` pattern used at + the extraction seam; the existing `_findAllNearbyDropped` stub pattern in `menu_gating_spec.lua` + for the menu-label seam. + +- No DCS live (L3+) integration scenario for this lot — the logic is pure and fully exercised by + the mocked seams above (agreed with the requester). + +## Out of Scope + +- Any redesign of the TRZ_ pickup-zone stock model (it tracks unit counts in `src/`, whereas legacy + tracks whole groups) — the fix's natural effect of restocking by survivor count on return is + in scope, but reconciling that broader group-vs-unit divergence with legacy is not. +- Any orphan-servant cleanup path other than the reactive `onUnitDead` hook — no periodic sweep, + and no retroactive cleanup of an orphaned servant already stranded on the map before this fix + ships (it is cleaned up the next time it takes further unit-death events, or left as-is). +- Vehicle/crate field extraction — this lot is scoped to troop groups only. +- Any change to `maxExtractDistance` or the extraction-radius mechanics themselves. + +## Further Notes + +- The new/changed menu strings (`"Extract: %1 (%2 troops)"`, `"%1 (%2 troops, %3m)"`) are new i18n + keys. Run `merge_CTLD.ps1` (which invokes `generate_i18n_dicts.ps1 -Apply`) before opening the + PR, and check that all four languages (EN/FR/ES/KO) get real translations, not empty stubs. +- `docs/pilot/troop-transport.md` and `.fr.md`, section "Extracting from the field", currently + document the distance-only label (`Bravo (25m)`) and need updating to the new format. +- `CHANGELOG.md` `[Unreleased]` entry required (this touches `src/`). diff --git a/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/01-logical-count-fix-and-filtering.md b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/01-logical-count-fix-and-filtering.md new file mode 100644 index 00000000..59b75811 --- /dev/null +++ b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/01-logical-count-fix-and-filtering.md @@ -0,0 +1,52 @@ +Status: ready + +# 01 — Logical count fix: field extraction reflects survivors + +## Parent + +`.backlog/FIX-FIELD-EXTRACT-CASUALTIES/PRD.md` + +## What to build + +Factor the existing servant-exclusion rule (already implemented in +`CTLDTroopGroup:_syncFromDCSGroup` — count a live DCS group's units, excluding any named with the +`SVNT` prefix) into a helper that can be applied against any live DCS `Group` object. + +Use this helper in `embarkFromField` to compute the extracted logical troop count from the DCS +group's **currently alive** units, replacing the current logic that prefers `stored.total` (the +headcount frozen at deploy time). `stored.total` / `stored.weight` stay in `_droppedTemplates`, but +are now used only to derive the original per-unit average weight +(`stored.weight / stored.total`), which still multiplies against the new, correct logical count to +produce proportional cargo weight. + +Apply the same helper in `_findNearestDropped` and `_findAllNearbyDropped` to exclude any dropped +group whose live logical count is 0 (a group where every real troop-role unit has died, leaving +only a `SVNT_*` mortar servant standing) — such a group is no longer a valid extraction candidate +at either lookup site. + +Add the `CHANGELOG.md` `[Unreleased]` entry for this fix. + +## Acceptance criteria + +- [ ] A shared helper counts a live DCS group's alive units excluding any named with the `SVNT` + prefix, and is used by `embarkFromField`, `_findNearestDropped`, and + `_findAllNearbyDropped`. +- [ ] `embarkFromField` on a group that lost units since deployment (mocked `getUnits()` returning + fewer live units than `stored.total`, including one `SVNT_`-prefixed mock unit among the + survivors) extracts the correct survivor count, excluding the servant — not `stored.total`. +- [ ] `embarkFromField` on a group with zero casualties still extracts the full original count (no + regression on the common, undamaged case). +- [ ] Cargo weight after extraction is proportional to the corrected survivor count (unchanged + `avgWeight * logicalCount` formula, fed the corrected count). +- [ ] `_findNearestDropped` and `_findAllNearbyDropped` never return a dropped group whose live + logical count is 0. +- [ ] The mortar unit itself (role `mortar`, not prefixed `SVNT`) is always included in the logical + count — only its cosmetic servant is excluded. +- [ ] A group carrying a JTAC extracts with the same logical count as before this change (no + regression). +- [ ] `CHANGELOG.md` has a new `[Unreleased]` entry describing the fix. +- [ ] `busted tests/ci/` passes clean; `luacheck --config .luacheckrc src/` clean. + +## Blocked by + +None — can start immediately. diff --git a/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/02-menu-troop-count-display.md b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/02-menu-troop-count-display.md new file mode 100644 index 00000000..9bcbc6e4 --- /dev/null +++ b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/02-menu-troop-count-display.md @@ -0,0 +1,46 @@ +Status: ready + +# 02 — Show troop count in the "Extract from field" menu + +## Parent + +`.backlog/FIX-FIELD-EXTRACT-CASUALTIES/PRD.md` + +## What to build + +Update the "Extract from field" F10 menu (built in `refreshMenuSection`) to show each nearby +dropped group's current logical troop count, using the helper introduced in ticket 01 and the +now-filtered results of `_findNearestDropped` / `_findAllNearbyDropped`: + +- Single nearby group (direct button) → `"Extract: %1 (%2 troops)"` (group name, logical count). +- Multiple nearby groups (submenu entries) → `"%1 (%2 troops, %3m)"` (group name, logical count, + distance in meters, floored as today). + +Both strings go through `ctld.tr(...)`, consistent with every other player-facing string in this +file. + +Run `merge_CTLD.ps1` (which invokes `generate_i18n_dicts.ps1 -Apply`) to sync the new i18n keys, +and fill in real translations for all four languages (EN/FR/ES/KO) — not empty stubs. + +Update `docs/pilot/troop-transport.md` and `docs/pilot/troop-transport.fr.md`, section +"Extracting from the field", which currently documents the distance-only label (`Bravo (25m)`), to +reflect the new format. + +## Acceptance criteria + +- [ ] With exactly one dropped group in extraction range, the F10 menu shows + `Extract: ( troops)` where `` is the group's current logical count. +- [ ] With two or more dropped groups in extraction range, the "Extract from field" submenu shows + ` ( troops, m)` for each entry, sorted by distance as today. +- [ ] A dropped group with 0 logical troops (servant-only residue) never appears in either menu + form (covered by ticket 01's filtering — verified here at the menu-rendering seam). +- [ ] New i18n keys are present with non-empty translations in all four language dictionaries + (EN/FR/ES/KO). +- [ ] `docs/pilot/troop-transport.md` and `.fr.md` reflect the new label format in the + "Extracting from the field" section. +- [ ] `busted tests/ci/` passes clean; `luacheck --config .luacheckrc src/` clean. + +## Blocked by + +- `01-logical-count-fix-and-filtering.md` (needs the shared logical-count helper and the filtered + nearby-group lookups). diff --git a/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/03-orphan-servant-cleanup.md b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/03-orphan-servant-cleanup.md new file mode 100644 index 00000000..e9167f73 --- /dev/null +++ b/.backlog/FIX-FIELD-EXTRACT-CASUALTIES/tickets/03-orphan-servant-cleanup.md @@ -0,0 +1,54 @@ +Status: ready + +# 03 — Despawn orphaned mortar servant on reaching zero logical troops + +## Parent + +`.backlog/FIX-FIELD-EXTRACT-CASUALTIES/PRD.md` + +## What to build + +**Scope widened during implementation**: `onUnitDead` is registered on the DCS event bridge as a +raw `S_EVENT_DEAD` handler (`bridge:register(tm, world.event.S_EVENT_DEAD, "onUnitDead")`, +`CTLD_core.lua`), which calls it as `onUnitDead(event)` — yet the function is written to receive +`unitName` (a string) directly, and never unwraps `event.initiator:getName()` the way every other +`S_EVENT_DEAD` handler in this codebase does (`onTransportDead`, `CTLDVehicleSpawner:onDead`, +`CTLDZoneManager:onDead`, `CTLDFOBManager:onDead`). In production this means `onUnitDead` never +matches a real dead unit — `_findGroupByAliveUnit` always misses — so it is currently a no-op, and +so is the JTAC deregistration-on-death path that depends on it (`CTLD_jtac.lua:778`). Without +fixing this, ticket 03's reactive cleanup would pass its unit tests (which call the method +directly) but never actually fire in a live mission. Fixed here as part of the same change, since +it is the same function and the same handler. + +Fix `onUnitDead` to accept the DCS `event` table and extract `local unitName = +event.initiator:getName()` first (guarding for a nil/missing initiator), matching the existing +pattern used by `onTransportDead` and the other bridge-registered `onDead` handlers. The rest of +the function keeps operating on `unitName` as before. + +Then, extend `onUnitDead`: after removing the dead unit from a *deployed* (dropped, not +in-transit) group's live view, use the shared logical-count helper (ticket 01) to check whether +the group's logical count is now 0 while its DCS group still has units (i.e. only `SVNT_*` +servants remain alive — the mortar operator died, the servant did not). + +If so, destroy the residual DCS group immediately and purge it from `_droppedGroups[coalition]` +and `_droppedTemplates[groupName]` — the same purge already performed by `_removeFromDropped`. Do +not change the existing JTAC-deregistration behavior already present in `onUnitDead` (beyond +fixing the event-unwrap that was preventing it from firing at all). + +## Acceptance criteria + +- [ ] `onUnitDead` accepts a DCS `event` table (as the bridge actually calls it) and extracts the + dead unit's name from `event.initiator`; a missing/nil `event.initiator` is a safe no-op. +- [ ] A dropped group where the last real troop-role unit dies while a `SVNT_` unit remains alive + triggers `group:destroy()` and removal from both `_droppedGroups` and `_droppedTemplates`. +- [ ] A dropped group where a non-last real troop-role unit dies (other real troops still alive) + triggers neither `destroy()` nor removal — the group stays on the field as today. +- [ ] A dropped group with no `SVNT_` units at all behaves exactly as today when its last unit + dies (no new code path engaged). +- [ ] JTAC deregistration on death (`deregisterJTAC`) actually fires given a real `event` shape — + covering both the pre-existing behavior this fix unblocks and ticket 03's own logic. +- [ ] `busted tests/ci/` passes clean; `luacheck --config .luacheckrc src/` clean. + +## Blocked by + +- `01-logical-count-fix-and-filtering.md` (needs the shared logical-count helper). diff --git a/.backlog/README.md b/.backlog/README.md index eced8430..5fcf2bd7 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -17,6 +17,7 @@ authored **per lot, when the lot is started** (not in batch). | `CHORE-UNTRACK-BUILT-ENGINE` | in progress | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | | `FEAT-CUSTOM-BEACON-SOUNDS` | planned | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | +| [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | | [`FIX-MENU-DOUBLE-MULTICREW`](FIX-MENU-DOUBLE-MULTICREW/PRD.md) | merged (PR #106) | F10 menu duplication on multi-crew aircraft (CH-47 pilot + copilot); menu loss when one crew member leaves a shared group. | — | | `DOCS-RELEASE-LIFECYCLE` | merged (PR #107) | Question from **FullGas**: does the exe download the latest build after a merge, or does a release have to be published? The answer is only in the code — `FEAT-ONE-CLICK-INSTALL` chose to **bundle** the engine (`--add-data "../../CTLD.lua;ctld_data"`, `release.yml` triggered on `published-v*` only), so an exe installs the engine of its own release, offline, and never updates itself. Nothing user-facing says it, so the natural assumption is the opposite. Documented in the README and the mission-maker guide (EN + FR), with the pre-release detail that goes with it: every rc publishes as a pre-release, so **no release carries the *Latest* badge** today and `releases/latest` redirects to the Releases index. No mechanism change. | `docs/release-lifecycle` | | `FIX-PARACHUTE-GROUP-NAME-COLLISION` | merged (PR #103) | `parachuteTroops` spawns the DCS group under the raw config `templateName` — two groups loaded from the same troop template collide on that name, and DCS destroys the first when the second (same template) lands. Fix: suffix group/unit names with `ctld.utils.getNextUniqId()`, as `CTLDObjectRegistry.spawnObject` already does everywhere else. | `fix/parachute-group-name-collision` | diff --git a/.claude/settings.json b/.claude/settings.json index d31eb383..3e8638f3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,7 @@ "permissions": { "allow": [ "Bash(*)", + "PowerShell(*)", "WebFetch(domain:wiki.hoggitworld.com)", "WebFetch(domain:github.com)" ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1f891a..32f7856c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed — field-extracted troop count now reflects casualties (FIX-FIELD-EXTRACT-CASUALTIES) + +- **Extracting a dropped troop group from the field now counts live survivors**, not the + headcount frozen at deploy time. Ten troops dropped, three killed, seven re-embarked — + matching the legacy monolith's live-unit-count behavior instead of the undeclared deviation + where the original deploy count was returned regardless of losses. Cosmetic mortar-servant + units (`SVNT_*`) stay excluded from the count; the mortar unit itself is always counted. +- **A dropped group reduced to zero real troops** (mortar operator dead, servant still + standing) is no longer offered for field extraction. +- **The "Extract from field" F10 menu now shows each group's current troop count**, e.g. + `Extract: Bravo (7 troops)` or `Bravo (7 troops, 25m)` in the multi-group submenu — letting a + pilot choose which group to extract based on what it will actually cost in capacity. +- **`onUnitDead` now actually fires.** It was registered on the DCS event bridge as a raw + `S_EVENT_DEAD` handler but written to expect a plain unit name, so it never matched a real + dead unit in a live mission — silently disabling both its own bookkeeping and JTAC + deregistration-on-death, which depends on it. Fixed to unwrap `event.initiator`, matching + every other bridge-registered death handler in the codebase. A group that loses its last real + trooper while a mortar servant survives is now despawned reactively at that moment. + ### Fixed — F10 menu duplication and multi-crew menu loss (FIX-MENU-DOUBLE-MULTICREW) - **F10 menu no longer duplicates** when a second crew member joins a multi-crew aircraft diff --git a/CONTEXT.md b/CONTEXT.md index 1ae58229..0ec0e335 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,6 +72,10 @@ redefined terms are added here in the same move as the decision that introduces **always** designates the AI-controlled transport units (auto pickup/dropoff via AIZ zones). - **Troop** — an infantry group loaded/unloaded by a transport; state machine (loaded → deployed → field-loaded → extracted). +- **Logical troop count** — the number of DCS units in a troop group that count toward transport + capacity, zone stock and player-facing counts. Excludes cosmetic crew (`SVNT_*` mortar servants, + spawned alongside a mortar unit but never counted as a troop). Distinct from the raw DCS unit + count (`#group:getUnits()`), which includes those servants. - **Crate** — a supply crate that can be spawned, slung, dropped by parachute, and **packed** / **unpacked** into a static or vehicle. - **Pack / unpack** — the sanctioned verbs for crate assembly/disassembly. The old term diff --git a/docs/pilot/troop-transport.fr.md b/docs/pilot/troop-transport.fr.md index 70d1cb0c..1932b60f 100644 --- a/docs/pilot/troop-transport.fr.md +++ b/docs/pilot/troop-transport.fr.md @@ -110,9 +110,14 @@ dépose est refusée avec un message vous demandant de descendre en stationnaire Pour récupérer un groupe déposé plus tôt, atterrissez dans le rayon d'extraction (par défaut **125 m**, `maxExtractDistance`) et utilisez **Embark / Extract Troops** : -- Un seul groupe à proximité → un bouton direct **Extract: <group name>**. -- Plusieurs à proximité → un sous-menu **Extract from field** listant chaque groupe avec sa - distance, par ex. `Bravo (25m)`. +- Un seul groupe à proximité → un bouton direct **Extract: <group name>**, affichant son + effectif actuel, par ex. `Extract: Bravo (7 troops)`. +- Plusieurs à proximité → un sous-menu **Extract from field** listant chaque groupe avec son + effectif actuel et sa distance, par ex. `Bravo (7 troops, 25m)`. + +L'effectif affiché correspond aux survivants, pas au nombre initialement déployé — les pertes +subies depuis la dépose ne sont pas comptées. Un groupe ayant perdu tous ses vrais soldats (ne +reste plus qu'un servant de mortier) n'apparaît plus ici. Vous devez être au sol, et vous avez besoin d'assez de capacité disponible pour prendre le groupe à bord. L'équipe extraite conserve son identité et ses éventuels ordres, de sorte que vous pouvez la diff --git a/docs/pilot/troop-transport.md b/docs/pilot/troop-transport.md index a759bbe0..54b2e156 100644 --- a/docs/pilot/troop-transport.md +++ b/docs/pilot/troop-transport.md @@ -104,9 +104,14 @@ telling you to hover lower or land. To recover a group that was dropped earlier, land within extract range (default **125 m**, `maxExtractDistance`) and use **Embark / Extract Troops**: -- One group nearby → a direct **Extract: <group name>** button. -- Several nearby → an **Extract from field** submenu listing each group with its distance, e.g. - `Bravo (25m)`. +- One group nearby → a direct **Extract: <group name>** button, showing its current troop + count, e.g. `Extract: Bravo (7 troops)`. +- Several nearby → an **Extract from field** submenu listing each group with its current troop + count and distance, e.g. `Bravo (7 troops, 25m)`. + +The troop count reflects survivors, not the number originally deployed — casualties taken since +drop-off are excluded. A group that lost every real trooper (only a mortar servant left standing) +no longer appears here. You must be on the ground, and you need enough spare capacity to take the group on board. The extracted team keeps its identity and any orders, so you can drop it again elsewhere. diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index 8abe90aa..a79b6429 100644 --- a/src/CTLD_i18n_en.lua +++ b/src/CTLD_i18n_en.lua @@ -15,7 +15,7 @@ if not ctld then ctld = {} end if not ctld.i18n then ctld.i18n = {} end ctld.i18n["en"] = {} -ctld.i18n["en"].translation_version = "1.16" +ctld.i18n["en"].translation_version = "1.17" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -456,7 +456,7 @@ ctld.i18n["en"]["Disembark Troops"] = "Disembark Troop ctld.i18n["en"]["Disembark All"] = "Disembark All" ctld.i18n["en"]["Embark / Extract Troops"] = "Embark / Extract Troops" ctld.i18n["en"]["Extract from field"] = "Extract from field" -ctld.i18n["en"]["Extract: %1"] = "Extract: %1" +-- STALE: ctld.i18n["en"]["Extract: %1"] = "Extract: %1" ctld.i18n["en"]["No troops onboard."] = "No troops onboard." ctld.i18n["en"]["Transport weight limit exceeded (%1 kg max)."] = "Transport weight limit exceeded (%1 kg max)." ctld.i18n["en"]["Vehicle ready for loading"] = "A %1 is ready for loading." @@ -568,3 +568,7 @@ ctld.i18n["en"]["dropOffZones is not read by CTLD 2 — declare each AI drop-off --- Keys added by generate_i18n_dicts.ps1 on 2026-08-01 ctld.i18n["en"][" AIZ[%1] ERROR '%2': name already taken by zone '%3' — entry ignored"] = " AIZ[%1] ERROR '%2': name already taken by zone '%3' — entry ignored" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-08-09 +ctld.i18n["en"]["%1 (%2 troops, %3m)"] = "%1 (%2 troops, %3m)" +ctld.i18n["en"]["Extract: %1 (%2 troops)"] = "Extract: %1 (%2 troops)" diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index b6769e38..3a7efbe0 100644 --- a/src/CTLD_i18n_es.lua +++ b/src/CTLD_i18n_es.lua @@ -10,7 +10,7 @@ if not ctld then ctld = {} end if not ctld.i18n then ctld.i18n = {} end ctld.i18n["es"] = {} -ctld.i18n["es"].translation_version = "1.16" +ctld.i18n["es"].translation_version = "1.17" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -445,7 +445,7 @@ ctld.i18n["es"]["Disembark Troops"] = "Desembarcar tro ctld.i18n["es"]["Disembark All"] = "Desembarcar todo" ctld.i18n["es"]["Embark / Extract Troops"] = "Embarcar / Extraer tropas" ctld.i18n["es"]["Extract from field"] = "Extraer del campo" -ctld.i18n["es"]["Extract: %1"] = "Extraer: %1" +-- STALE: ctld.i18n["es"]["Extract: %1"] = "Extraer: %1" ctld.i18n["es"]["No troops onboard."] = "No hay tropas a bordo." ctld.i18n["es"]["Transport weight limit exceeded (%1 kg max)."] = "Límite de peso superado (%1 kg máx)." ctld.i18n["es"]["Vehicle ready for loading"] = "Un %1 está listo para cargar." @@ -572,3 +572,7 @@ ctld.i18n["es"]["dropOffZones is not read by CTLD 2 — declare each AI drop-off --- Keys added by generate_i18n_dicts.ps1 on 2026-08-01 ctld.i18n["es"][" AIZ[%1] ERROR '%2': name already taken by zone '%3' — entry ignored"] = "" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-08-09 +ctld.i18n["es"]["%1 (%2 troops, %3m)"] = "%1 (%2 tropas, %3 m)" +ctld.i18n["es"]["Extract: %1 (%2 troops)"] = "Extraer: %1 (%2 tropas)" diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index 5d324548..097bcd8e 100644 --- a/src/CTLD_i18n_fr.lua +++ b/src/CTLD_i18n_fr.lua @@ -9,7 +9,7 @@ if not ctld then ctld = {} end if not ctld.i18n then ctld.i18n = {} end ctld.i18n["fr"] = {} -ctld.i18n["fr"].translation_version = "1.16" +ctld.i18n["fr"].translation_version = "1.17" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -452,7 +452,7 @@ ctld.i18n["fr"]["Disembark Troops"] = "Débarquer les ctld.i18n["fr"]["Disembark All"] = "Tout débarquer" ctld.i18n["fr"]["Embark / Extract Troops"] = "Embarquer / Extraire des troupes" ctld.i18n["fr"]["Extract from field"] = "Extraire du terrain" -ctld.i18n["fr"]["Extract: %1"] = "Extraire : %1" +-- STALE: ctld.i18n["fr"]["Extract: %1"] = "Extraire : %1" ctld.i18n["fr"]["No troops onboard."] = "Aucune troupe à bord." ctld.i18n["fr"]["Transport weight limit exceeded (%1 kg max)."] = "Limite de poids dépassée (%1 kg max)." ctld.i18n["fr"]["Vehicle ready for loading"] = "Un %1 est prêt à être chargé." @@ -602,3 +602,7 @@ ctld.i18n["fr"]["dropOffZones is not read by CTLD 2 — declare each AI drop-off --- Keys added by generate_i18n_dicts.ps1 on 2026-08-01 ctld.i18n["fr"][" AIZ[%1] ERROR '%2': name already taken by zone '%3' — entry ignored"] = " AIZ[%1] ERREUR '%2' : nom déjà pris par la zone '%3' — entrée ignorée" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-08-09 +ctld.i18n["fr"]["%1 (%2 troops, %3m)"] = "%1 (%2 soldats, %3 m)" +ctld.i18n["fr"]["Extract: %1 (%2 troops)"] = "Extraire : %1 (%2 soldats)" diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 13802937..1fade207 100644 --- a/src/CTLD_i18n_ko.lua +++ b/src/CTLD_i18n_ko.lua @@ -10,7 +10,7 @@ if not ctld then ctld = {} end if not ctld.i18n then ctld.i18n = {} end ctld.i18n["ko"] = {} -ctld.i18n["ko"].translation_version = "1.16" +ctld.i18n["ko"].translation_version = "1.17" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -288,7 +288,7 @@ ctld.i18n["ko"]["Disembark Troops"] = "병력 하차" ctld.i18n["ko"]["Disembark All"] = "전체 하차" ctld.i18n["ko"]["Embark / Extract Troops"] = "병력 탑승 / 추출" ctld.i18n["ko"]["Extract from field"] = "현장에서 추출" -ctld.i18n["ko"]["Extract: %1"] = "추출: %1" +-- STALE: ctld.i18n["ko"]["Extract: %1"] = "추출: %1" ctld.i18n["ko"]["No troops onboard."] = "탑승 병력 없음." ctld.i18n["ko"]["Transport weight limit exceeded (%1 kg max)."] = "수송 중량 한계 초과 (최대 %1 kg)." ctld.i18n["ko"]["Vehicle ready for loading"] = "%1이(가) 적재 준비되었습니다." @@ -441,3 +441,7 @@ ctld.i18n["ko"]["dropOffZones is not read by CTLD 2 — declare each AI drop-off --- Keys added by generate_i18n_dicts.ps1 on 2026-08-01 ctld.i18n["ko"][" AIZ[%1] ERROR '%2': name already taken by zone '%3' — entry ignored"] = "" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-08-09 +ctld.i18n["ko"]["%1 (%2 troops, %3m)"] = "%1 (%2명, %3 m)" +ctld.i18n["ko"]["Extract: %1 (%2 troops)"] = "추출: %1 (%2명)" diff --git a/src/CTLD_troop.lua b/src/CTLD_troop.lua index a081c7a1..4bcc5f5f 100644 --- a/src/CTLD_troop.lua +++ b/src/CTLD_troop.lua @@ -30,6 +30,14 @@ CTLDTroopGroup.STATE = { RETURNED_TO_TRZ = "RETURNED_TO_TRZ", } +-- Cosmetic mortar-servant units are spawned by CTLDObjectRegistry.spawnObject as +-- "SVNT-" (namePrefix "SVNT" + a numeric suffix — see _registerOneTemplate below). +-- They never count as a real troop for capacity, weight, or logical-count purposes. +-- Single predicate shared by every alive/logical-count filter in this file. +local function _isServantUnitName(name) + return name:match("^SVNT") ~= nil +end + --- Constructor. -- @param data table: -- templateKey (string|nil) CTLDObjectRegistry key (nil for recovered groups) @@ -97,8 +105,7 @@ function CTLDTroopGroup:_syncFromDCSGroup(dcsGroup) for _, unit in ipairs(units) do if unit:isExist() then local name = unit:getName() - -- SVNT units are mortar servants (cosmetic crew); exclude from tracking and count. - if not name:match("^SVNT") then + if not _isServantUnitName(name) then self._aliveUnits[name] = unit if name:match("^JTAC") then self._jtacUnits[name] = true @@ -1008,9 +1015,10 @@ function CTLDTroopManager:embarkFromField(unit) local country = nearest.group:getUnit(1):getCountry() local stored = self._droppedTemplates[nearest.groupName] or {} - -- Logical troop count: prefer stored.total (excludes mortar servants) so that servants - -- do not inflate the capacity check on re-embark (Bug 2). - local logicalCount = (stored.total and stored.total > 0) and stored.total or groupSize + -- Logical troop count: live survivors, excluding mortar servants (SVNT_*) so they never + -- inflate the capacity check (Bug 2) — and so combat losses are reflected on re-embark + -- (FIX-FIELD-EXTRACT-CASUALTIES), matching the legacy monolith's live-count behavior. + local logicalCount = self:_countLogicalUnits(nearest.group) -- Weight: proportional to surviving logical units using original avg weight (BUG-07) local avgWeight = (stored.weight and stored.total and stored.total > 0) @@ -1160,9 +1168,24 @@ end --- Called from CTLDDCSEventBridge on S_EVENT_DEAD. -- Removes the dead unit from _aliveUnits and _jtacUnits of the owning group. -- Deregisters the JTAC from CTLDJTACManager if the dead unit was a JTAC. +-- If the death leaves a deployed group with zero real troops but some DCS units still standing +-- (a mortar servant orphaned by its operator's death), the residual group is despawned and +-- purged (FIX-FIELD-EXTRACT-CASUALTIES). -- NOTE: wasJtac is captured BEFORE _removeDeadUnit clears _jtacUnits[unitName]. --- @param unitName string DCS unit name -function CTLDTroopManager:onUnitDead(unitName) +-- @param event table DCS S_EVENT_DEAD event (event.initiator = the dead Unit) +function CTLDTroopManager:onUnitDead(event) + local initiator = event and event.initiator + if not initiator then + ctld.utils.log("DEBUG", "onUnitDead: event has no initiator — skipping") + return + end + local ok, unitName = pcall(function() return initiator:getName() end) + if not ok or not unitName then + ctld.utils.log("DEBUG", "onUnitDead: initiator:getName() failed (%s) — skipping", + tostring(unitName)) + return + end + local grp = self:_findGroupByAliveUnit(unitName) if not grp then ctld.utils.log("INFO", "onUnitDead: no group found for unit '%s' — skipping", unitName) @@ -1177,6 +1200,16 @@ function CTLDTroopManager:onUnitDead(unitName) CTLDJTACManager.getInstance():deregisterJTAC(unitName) ctld.utils.log("INFO", "onUnitDead: JTAC unit '%s' deregistered", unitName) end + + -- Orphaned servant cleanup: deployed group, zero real troops left, servant(s) still standing. + if grp.dcsGroup and grp.state == CTLDTroopGroup.STATE.DEPLOYED + and grp:getLogicalCount() == 0 and grp:getAliveCount() > 0 then + local groupName = grp.dcsGroup:getName() + grp.dcsGroup:destroy() + self:_removeFromDropped(grp.coalitionId, groupName) + ctld.utils.log("INFO", + "onUnitDead: despawned orphaned servant-only residue for group '%s'", groupName) + end end --- Returns the count of alive units (helper for onUnitDead logging). @@ -1187,6 +1220,18 @@ function CTLDTroopGroup:getAliveCount() return n end +--- Returns the count of alive units that count as real troops (see _isServantUnitName). +-- Mirrors CTLDTroopManager:_countLogicalUnits, but reads the in-memory _aliveUnits +-- snapshot instead of re-querying the DCS group. +-- @return number +function CTLDTroopGroup:getLogicalCount() + local n = 0 + for name in pairs(self._aliveUnits) do + if not _isServantUnitName(name) then n = n + 1 end + end + return n +end + function CTLDTroopManager:cleanupDeadTransports() local jm = CTLDJTACManager.getInstance() for unitName, list in pairs(self._inTransit) do @@ -1290,7 +1335,24 @@ function CTLDTroopManager:_countDroppedTroops(coalition) return count end --- Returns { groupName, group, distM } for the nearest dropped group within maxExtractDistance, or nil. +-- Returns the count of a live DCS group's units that count as real troops +-- (see _isServantUnitName). Mirrors CTLDTroopGroup:_syncFromDCSGroup's filter. +-- @param dcsGroup Group live DCS group +-- @return number +function CTLDTroopManager:_countLogicalUnits(dcsGroup) + local units = dcsGroup:getUnits() or {} + local count = 0 + for i = 1, #units do + local u = units[i] + if u and u:isExist() and not _isServantUnitName(u:getName()) then + count = count + 1 + end + end + return count +end + +-- Returns { groupName, group, distM } for the nearest dropped group within maxExtractDistance, or +-- nil. Excludes a group with zero logical troops (e.g. only a mortar servant left standing). function CTLDTroopManager:_findNearestDropped(unit, coalition) local pt = unit:getPoint() local maxDist = ctld.gs("maxExtractDistance") @@ -1298,7 +1360,7 @@ function CTLDTroopManager:_findNearestDropped(unit, coalition) for _, name in ipairs(self._droppedGroups[coalition]) do local g = Group.getByName(name) - if g and g:isExist() and #g:getUnits() > 0 then + if g and g:isExist() and #g:getUnits() > 0 and self:_countLogicalUnits(g) > 0 then local leader = g:getUnit(1) if leader then local gpt = leader:getPoint() @@ -1412,6 +1474,7 @@ function CTLDTroopManager:_menuDisembark(unit) end --- Returns all dropped groups within maxExtractDistance of unit, sorted by distance asc (pt3). +-- Excludes a group with zero logical troops (e.g. only a mortar servant left standing). -- @param unit DCS Unit -- @param coalition number -- @return table array of { groupName, group, distM } @@ -1421,7 +1484,7 @@ function CTLDTroopManager:_findAllNearbyDropped(unit, coalition) local found = {} for _, name in ipairs(self._droppedGroups[coalition]) do local g = Group.getByName(name) - if g and g:isExist() and #g:getUnits() > 0 then + if g and g:isExist() and #g:getUnits() > 0 and self:_countLogicalUnits(g) > 0 then local leader = g:getUnit(1) if leader then local gpt = leader:getPoint() @@ -1913,8 +1976,9 @@ function CTLDTroopManager:refreshMenuSection(playerObj, overrideInAir) if #nearbyGroups == 1 then -- Single nearby group: direct button local capturedName = nearbyGroups[1].groupName + local capturedCount = self:_countLogicalUnits(nearbyGroups[1].group) menu:addCommand({ root, troopSub, embarkSub }, - ctld.tr("Extract: %1", nearbyGroups[1].groupName), + ctld.tr("Extract: %1 (%2 troops)", capturedName, capturedCount), function(arg) local u = Unit.getByName(arg.unitName) if not u then return end @@ -1927,8 +1991,9 @@ function CTLDTroopManager:refreshMenuSection(playerObj, overrideInAir) menu:addSubMenu({ root, troopSub, embarkSub }, extractSub) for _, entry in ipairs(nearbyGroups) do local capturedName = entry.groupName + local capturedCount = self:_countLogicalUnits(entry.group) menu:addCommand({ root, troopSub, embarkSub, extractSub }, - string.format("%s (%dm)", entry.groupName, math.floor(entry.distM)), + ctld.tr("%1 (%2 troops, %3m)", entry.groupName, capturedCount, math.floor(entry.distM)), function(arg) local u = Unit.getByName(arg.unitName) if not u then return end diff --git a/tests/ci/functional/troop_manager_spec.lua b/tests/ci/functional/troop_manager_spec.lua index 7ae850bb..09e9aca7 100644 --- a/tests/ci/functional/troop_manager_spec.lua +++ b/tests/ci/functional/troop_manager_spec.lua @@ -387,4 +387,265 @@ describe("CTLDTroopManager", function() end) + -- ── F-036b : embarkFromField reflects real casualties (logical count) ── + describe("F-036b — embarkFromField casualties (logical count)", function() + + local _origGetByName + + local function registerDropped(groupName, unitNames, storedTotal, storedWeight) + local unitPos = mockUnit:getPoint() + local units = {} + for _, uname in ipairs(unitNames) do + table.insert(units, { + _pos = { x=unitPos.x+10, y=unitPos.y, z=unitPos.z+10 }, + _country = 2, + getPoint = function(self) return self._pos end, + getCountry = function(self) return self._country end, + getName = function(self) return uname end, + isExist = function(self) return true end, + }) + end + local mockGroup = { + _name = groupName, + getName = function(self) return self._name end, + getUnits = function(self) return units end, + getUnit = function(self, i) return units[i] end, + isExist = function(self) return true end, + destroy = function(self) end, + } + Group.getByName = function(name) + if name == groupName then return mockGroup end + return _origGetByName(name) + end + + local coa = mockUnit:getCoalition() + tm._droppedGroups[coa] = { groupName } + tm._droppedTemplates[groupName] = { + key = "Alpha_Squad", + name = "Alpha Squad", + weight = storedWeight, + total = storedTotal, + } + end + + before_each(function() + _origGetByName = Group.getByName + tm._isInAir = function(self, u) return false end + end) + + after_each(function() + Group.getByName = _origGetByName + end) + + it("extracted count reflects survivors, not the count frozen at deploy time", function() + -- Deployed with 10 troops (stored.total=10); only 6 real troops + 1 servant survived. + registerDropped("MockDropped_036b_1", + { "INF_u1", "INF_u2", "INF_u3", "INF_u4", "INF_u5", "INF_u6", "SVNT_u1" }, + 10, 1300) + + tm:embarkFromField(mockUnit) + local list = tm:getInTransit("UH-1H-1") + assert.is_not_nil(list) + assert.equals(6, list[1].unitTotal) + end) + + it("mortar servant is excluded even when the mortar itself survives", function() + -- No casualties: 1 inf + 1 mortar deployed (stored.total=2), servant still alive. + registerDropped("MockDropped_036b_2", + { "INF_u1", "MORTAR_u1", "SVNT_u1" }, + 2, 260) + + tm:embarkFromField(mockUnit) + local list = tm:getInTransit("UH-1H-1") + assert.equals(2, list[1].unitTotal) + end) + + it("weight scales proportionally with the survivor count", function() + -- stored: 10 troops @ 130 kg avg = 1300. Survivors: 6 real troops + 1 servant. + registerDropped("MockDropped_036b_3", + { "INF_u1", "INF_u2", "INF_u3", "INF_u4", "INF_u5", "INF_u6", "SVNT_u1" }, + 10, 1300) + + tm:embarkFromField(mockUnit) + local list = tm:getInTransit("UH-1H-1") + assert.equals(780, list[1].weight) + end) + + it("no casualties → extracted count still equals the original deploy count", function() + registerDropped("MockDropped_036b_4", { "INF_u1", "INF_u2", "INF_u3" }, 3, 390) + + tm:embarkFromField(mockUnit) + local list = tm:getInTransit("UH-1H-1") + assert.equals(3, list[1].unitTotal) + end) + + end) + + -- ── F-036c : nearby-group lookups exclude zero-logical-count groups ──── + describe("F-036c — nearby dropped-group lookups filter servant-only groups", function() + + local _origGetByName + + local function mockDroppedGroup(groupName, unitNames) + local unitPos = mockUnit:getPoint() + local units = {} + for _, uname in ipairs(unitNames) do + table.insert(units, { + _pos = { x=unitPos.x+5, y=unitPos.y, z=unitPos.z+5 }, + _country = 2, + getPoint = function(self) return self._pos end, + getCountry = function(self) return self._country end, + getName = function(self) return uname end, + isExist = function(self) return true end, + }) + end + return { + _name = groupName, + getName = function(self) return self._name end, + getUnits = function(self) return units end, + getUnit = function(self, i) return units[i] end, + isExist = function(self) return true end, + } + end + + before_each(function() + _origGetByName = Group.getByName + end) + + after_each(function() + Group.getByName = _origGetByName + end) + + it("_findNearestDropped skips a group with only a servant alive", function() + local servantOnly = mockDroppedGroup("MockDropped_036c_1", { "SVNT_u1" }) + Group.getByName = function(name) + if name == "MockDropped_036c_1" then return servantOnly end + return _origGetByName(name) + end + local coa = mockUnit:getCoalition() + tm._droppedGroups[coa] = { "MockDropped_036c_1" } + + local nearest = tm:_findNearestDropped(mockUnit, coa) + assert.is_nil(nearest) + end) + + it("_findAllNearbyDropped excludes a servant-only group but keeps a real one", function() + local servantOnly = mockDroppedGroup("MockDropped_036c_2", { "SVNT_u1" }) + local realGroup = mockDroppedGroup("MockDropped_036c_3", { "INF_u1", "INF_u2" }) + Group.getByName = function(name) + if name == "MockDropped_036c_2" then return servantOnly end + if name == "MockDropped_036c_3" then return realGroup end + return _origGetByName(name) + end + local coa = mockUnit:getCoalition() + tm._droppedGroups[coa] = { "MockDropped_036c_2", "MockDropped_036c_3" } + + local found = tm:_findAllNearbyDropped(mockUnit, coa) + assert.equals(1, #found) + assert.equals("MockDropped_036c_3", found[1].groupName) + end) + + end) + + -- ── F-037 : onUnitDead — event unwrap + orphaned servant cleanup (FIX-FIELD-EXTRACT-CASUALTIES) ── + describe("F-037 — onUnitDead", function() + + local _origGetByName + + local function mockDeadUnit(uname) + return { + _name = uname, + getName = function(self) return self._name end, + isExist = function(self) return true end, + getCountry = function(self) return 2 end, + } + end + + -- groupUnitNames: names alive in the DCS group INCLUDING the one about to die + -- (matches _findGroupByAliveUnit's contract: it must still be found isExist()==true + -- in the group roster at the instant the death event is processed). + local function registerDroppedGroup(groupName, groupUnitNames) + local units = {} + for _, uname in ipairs(groupUnitNames) do + units[#units + 1] = mockDeadUnit(uname) + end + local destroyed = { called = false } + local mockGroup = { + _name = groupName, + getName = function(self) return self._name end, + getUnits = function(self) return units end, + getUnit = function(self, i) return units[i] end, + isExist = function(self) return true end, + destroy = function(self) destroyed.called = true end, + } + Group.getByName = function(name) + if name == groupName then return mockGroup end + return _origGetByName and _origGetByName(name) or nil + end + local coa = mockUnit:getCoalition() + tm._droppedGroups[coa] = { groupName } + tm._droppedTemplates[groupName] = { key = "Alpha_Squad", name = "Alpha Squad" } + return destroyed + end + + before_each(function() + _origGetByName = Group.getByName + end) + + after_each(function() + Group.getByName = _origGetByName + end) + + it("nil/missing event.initiator is a safe no-op", function() + assert.has_no.errors(function() tm:onUnitDead({}) end) + assert.has_no.errors(function() tm:onUnitDead(nil) end) + end) + + it("last real troop dies, servant remains: group destroyed and purged", function() + local coa = mockUnit:getCoalition() + local destroyed = registerDroppedGroup("MockDropped_037_1", { "INF_u1", "SVNT_u1" }) + + tm:onUnitDead({ initiator = mockDeadUnit("INF_u1") }) + + assert.is_true(destroyed.called) + assert.equals(0, #tm._droppedGroups[coa]) + assert.is_nil(tm._droppedTemplates["MockDropped_037_1"]) + end) + + it("non-last real troop dies, others remain: group untouched", function() + local coa = mockUnit:getCoalition() + local destroyed = registerDroppedGroup("MockDropped_037_2", + { "INF_u1", "INF_u2", "SVNT_u1" }) + + tm:onUnitDead({ initiator = mockDeadUnit("INF_u1") }) + + assert.is_false(destroyed.called) + assert.equals(1, #tm._droppedGroups[coa]) + assert.is_not_nil(tm._droppedTemplates["MockDropped_037_2"]) + end) + + it("no servant present: last unit dies, no new cleanup path engaged", function() + local coa = mockUnit:getCoalition() + local destroyed = registerDroppedGroup("MockDropped_037_3", { "INF_u1" }) + + tm:onUnitDead({ initiator = mockDeadUnit("INF_u1") }) + + assert.is_false(destroyed.called) + assert.equals(1, #tm._droppedGroups[coa]) + end) + + it("JTAC deregistration fires given the real event shape", function() + registerDroppedGroup("MockDropped_037_4", { "JTAC_u1", "INF_u1" }) + local jm = CTLDJTACManager.getInstance() + local deregistered = {} + jm.deregisterJTAC = function(self, name) deregistered[#deregistered + 1] = name end + + tm:onUnitDead({ initiator = mockDeadUnit("JTAC_u1") }) + + assert.equals(1, #deregistered) + assert.equals("JTAC_u1", deregistered[1]) + end) + + end) + end) diff --git a/tests/ci/unit/menu_gating_spec.lua b/tests/ci/unit/menu_gating_spec.lua index a840f54b..f0a87be2 100644 --- a/tests/ci/unit/menu_gating_spec.lua +++ b/tests/ci/unit/menu_gating_spec.lua @@ -449,6 +449,95 @@ describe("F10 menu gating (config + capability) + player-manager wiring", functi end) end) + -- ── F-089b : Extract from field menu shows troop counts (FIX-FIELD-EXTRACT-CASUALTIES) ── + describe("F-089b — Extract from field menu shows troop counts", function() + local tm, playerObj + local _origGetByName + + local function mockDroppedGroup(unitNames) + local units = {} + for _, uname in ipairs(unitNames) do + table.insert(units, { + getName = function() return uname end, + isExist = function() return true end, + }) + end + return { getUnits = function() return units end } + end + + before_each(function() + resetSingletons() + EventDispatcher.getInstance() + CTLDDCSEventBridge.getInstance() + CTLDZoneManager.getInstance() + CTLDPlayerManager.getInstance() + tm = CTLDTroopManager.getInstance() + + tm._isInAir = function() return false end + + local zm = CTLDZoneManager.getInstance() + zm.getTroopZonesForCoalition = function() return {} end + + _origGetByName = Unit.getByName + Unit.getByName = function(n) + if n == "BLUE_UH1H_1" then + return { + getName = function() return "BLUE_UH1H_1" end, + isExist = function() return true end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + } + end + return _origGetByName and _origGetByName(n) or nil + end + + playerObj = makePlayer({ + unitName = "BLUE_UH1H_1", coalition = coalition.side.BLUE, typeName = "UH-1H", + }) + end) + + after_each(function() + Unit.getByName = _origGetByName + end) + + it("single nearby group: direct button shows troop count", function() + tm._findAllNearbyDropped = function() + return { { + groupName = "Bravo", + group = mockDroppedGroup({ "INF_u1", "INF_u2", "INF_u3", "INF_u4", "INF_u5", "SVNT_u1" }), + distM = 25, + } } + end + CTLDPlayerManager.getInstance():buildMenu(playerObj) + local menu = ctld.MenuManager:getInstance():getMenuByGroupId(playerObj.groupId) + + local path = { ROOT, tr("Troop Commands"), tr("Embark / Extract Troops"), + tr("Extract: %1 (%2 troops)", "Bravo", 5) } + assert.is_true(has(menu, path)) + end) + + it("multiple nearby groups: submenu entries show troop count and distance", function() + tm._findAllNearbyDropped = function() + return { + { groupName = "Bravo", group = mockDroppedGroup({ "INF_u1", "INF_u2" }), distM = 25 }, + { groupName = "Charlie", group = mockDroppedGroup({ "INF_u1", "SVNT_u1" }), distM = 87 }, + } + end + CTLDPlayerManager.getInstance():buildMenu(playerObj) + local menu = ctld.MenuManager:getInstance():getMenuByGroupId(playerObj.groupId) + + local base = { ROOT, tr("Troop Commands"), tr("Embark / Extract Troops"), tr("Extract from field") } + local bravoPath = {} + for _, p in ipairs(base) do table.insert(bravoPath, p) end + table.insert(bravoPath, tr("%1 (%2 troops, %3m)", "Bravo", 2, 25)) + assert.is_true(has(menu, bravoPath)) + + local charliePath = {} + for _, p in ipairs(base) do table.insert(charliePath, p) end + table.insert(charliePath, tr("%1 (%2 troops, %3m)", "Charlie", 1, 87)) + assert.is_true(has(menu, charliePath)) + end) + end) + -- ── F-025 : OnVehicleLoaded/Unloaded → CTLDPlayer.loadedVehicles ───────── describe("F-025 — player-manager maintains loadedVehicles on vehicle events", function() local pm, playerObj, transport, veh diff --git a/tests/dcs/pilotPassive/scenarioTroopsFullCycle_v2.lua b/tests/dcs/pilotPassive/scenarioTroopsFullCycle_v2.lua index 84e20c19..e45c7a80 100644 --- a/tests/dcs/pilotPassive/scenarioTroopsFullCycle_v2.lua +++ b/tests/dcs/pilotPassive/scenarioTroopsFullCycle_v2.lua @@ -576,8 +576,9 @@ elseif step == 6 then log("Step 6: killing JTAC '" .. deadUnitName .. "' | its target='" .. tostring(deadJtacTarget) .. "'") - -- Simulate S_EVENT_DEAD for the JTAC unit - troopMgr:onUnitDead(deadUnitName) + -- Simulate S_EVENT_DEAD for the JTAC unit (matches the real bridge call shape: + -- onUnitDead receives the DCS event table, not a bare unit name) + troopMgr:onUnitDead({ initiator = Unit.getByName(deadUnitName) }) -- Verify: dead JTAC removed, its target freed, alive JTAC still claims its target check("F-T6.4", "dead JTAC removed from jtacMgr",