diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md new file mode 100644 index 00000000..fa591a3f --- /dev/null +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -0,0 +1,62 @@ +# FEAT-FAT-COW — a CH-47 that lands in the field and becomes a forward refuel/rearm point + +**Status:** 🔄 in-progress — ticket 01 phase 1 done (2026-10-26): a runtime Invisible FARP serves a player already in game, enforces a finite stock, and needs a supplier in range, which the `FARP Fuel Depot` static alone satisfies, for fuel and weapons, without any vehicle. Phase 2 (a remote multiplayer client) not run: Zip hosted, so his view was the server's; the design re-applies the stock on every birth instead. Tickets 02-05 implemented (Zip, 2026-10-26: "implémente"), then reworked after the first server run (loadouts, F10-commanded AI cow, statuses, RTB); PR #273. Ticket 06, the live check with the squadron, waits for Zip. + +Designed with Zip on 2026-10-26 (VMCT session). Zip decided that the runtime belongs in CTLD, not in the VMCT Lua; VMCT will vendor this CTLD and add a static mission-editing tool for it afterwards. + +## Problem Statement + +Attack helicopters (typically AH-64D) on a long sortie have to fly back to an airfield or a mission-placed FARP to refuel and rearm. +The US Army answers this with the "Fat Cow": a CH-47 that lands near them and serves fuel from its internal tanks. +Army.mil (Feb 2020) calls it "a rapidly employed FARP" enabling "a quick refuel and rearm" of AH-64s; DVIDS (May 2022) mentions two ERFS tanks of 800 gal. + +DCS has no fuel-transfer API, and the CH-47F definition has no fuel-transfer capability. +So the cow can only be a FARP spawned next to it, stocked with what the cow carries. + +ED announced a native Fat Cow on 2024-04-19 and again on 2024-08-23; it is absent from DCS 2.9.30.28738. + +## Decisions (Zip, 2026-10-26) + +1. **Two modes, one core.** + - **AI:** a cow can be requested only on the ground, inside a friendly CTLD logistic zone. It escorts the requesting flight, lands near it when it lands, and deploys the FARP after **60 s** on the ground. When the flight takes off, it packs the FARP and follows again. It flies back to its logistic zone and despawns when its stock is empty, and also on an F10 "release the Fat Cow" command. + - **Piloted:** a player CH-47F (`CH-47Fbl1`) loads "cow" crates in a logistic zone: one crate = one 800 gal ERFS tank (about 2 400 kg of fuel, that mass in the hold), plus optional ammo crates (Hellfire, 30 mm). Once landed, an F10 command deploys the FARP, and another packs it. +2. **Materialisation:** an Invisible FARP, a few crates around and 2 or 3 infantry. **No vehicles** (Zip: where would they come from?). Reuses the scene mechanism of `src/scenes/` (`CTLD_countrysideFarpScene.lua`, `CTLD_farpScene.lua`). +3. **Stock = what the cow carries**, as a finite FARP warehouse, not unlimited. **AI default: full fuel (3 × 800 gal, about 7 200 kg) and 6 Hellfires**, configurable in the CTLD config. The 6 is Zip's call: no source gives the Hellfire load of a cow. +4. **Ticket 01 is an in-game spike, before building the rest.** If an answer kills the design, stop and report to Zip. + +## What the spike must settle, and why it is not obvious + +- `src/scenes/CTLD_countrysideFarpScene.lua` contradicts itself: its header says the Invisible FARP "creates a proper DCS airbase (warehouse…)", its step 11 says "Invisible FARP airbases (DCS built-in) return nil for getWarehouse()". The finite stock of decision 3 needs a warehouse. +- VMCT refills FARP warehouses on every player birth (`veafGrass.fillFarpWarehouse`) because warehouses do not sync over the network, and an ED forum guide (2024) says dynamically spawned FARPs start empty and resupply badly by script. +- RotorOps PERKS does not spawn its Fat Cow FARP: it teleports FARP statics pre-placed in the Mission Editor (`RotorOpsPerks.teleportStatic`), and skips the deployment when enemies are within 1.1 nm, because DCS refuses to rearm/refuel there. Both are hints that a runtime FARP is fragile. +- DCS historically needed a fuel truck and an ammo truck near a FARP. Decision 2 has none; if DCS still needs them, the fallback is a vehicle hidden under the cow's fuselage. + +## Design (to be confirmed by the spike) + +- **Core:** a `CTLDFatCow` entity (Manager + Entity pattern) holding a stock (fuel kg, items by warehouse name) and a state (`flying`, `landed`, `deployed`, `packing`, `returning`). Deploy = run the cow scene next to the cow, then set the FARP warehouse to the stock. Pack = read the warehouse back into the stock (what the players consumed is gone), then remove the scene. Empty stock → the AI cow goes home. +- **AI mode** listens to `S_EVENT_LAND` / `S_EVENT_TAKEOFF` of the escorted flight (CTLD already subscribes to these for players and AI transports), and drives the cow with DCS tasks (`Follow`/escort, `Land`). +- **Piloted mode** reuses the crate system: cow crates are crate models whose weight is the fuel mass; the deploy command sums the loaded cow crates into the stock. +- **Config** under one `fatCow` block in `src/CTLD_config.yaml` (adds keys → `configVersion` bump and a catalogue shape, per `CLAUDE.md`). + +## Contract for VMCT + +Documented for mission makers in `docs/mission-maker/fat-cow.md` (EN + FR). + +- **A mission-placed AI cow** is a **late-activated helicopter group** declared in `fatCowAICows` with the flight it escorts: `fatCowAICows = [{ group: "", escort: "", loadout: }]`. CTLD activates it once the escorted group is in the world (at start for an AI flight, at the first player's birth for a client flight). The engine never parses a cow's name (see `CONTEXT.md`, "Config-referenced zone"). +- **Settings:** `fatCowEnabled`, `fatCowTypes` (`[CH-47Fbl1]`), `fatCowCrates` (`desc`, `weight`, and `fuelKg` or `item` + `count`), `fatCowLoadouts` (`desc`, `fuelKg`, `items`: what a cow carries, piloted or AI), `fatCowDeployDelay` (60 s), `fatCowAIType` (`CH-47Fbl1`; empty disables the request), `fatCowAICows` (`fatCowAIFuelKg` and `fatCowAIItems`, in no release, were replaced by the loadouts). Catalogue version 2.3.0. +- A requested AI cow is created by script (`coalition.addGroup`, type `fatCowAIType`), so no template group is needed in the mission; the draft's `fatCow.aiTemplate` was dropped for that reason. + +## Tickets + +| # | Ticket | Status | +|---|---|---| +| 01 | [In-game spike: a runtime FARP, its finite stock, its vehicles](tickets/01-spike-runtime-farp.md) | 🧑 phase 1 done (a yes, b yes server side, c the FARP Fuel Depot alone suffices, no vehicle); phase 2 to run | +| 02 | [Core: a cow's stock, its scene, deploy and pack](tickets/02-core-stock-and-scene.md) | ✅ done | +| 03 | [Piloted cow: cow crates in a CH-47F and the F10 commands](tickets/03-piloted-cow.md) | ✅ done | +| 04 | [AI cow: requested by a flight, escorts it, serves it, goes home](tickets/04-ai-cow.md) | ✅ done | +| 05 | [Config, VMCT contract doc, CHANGELOG and release notes](tickets/05-config-docs-release.md) | ✅ done (release-notes jab in the CHANGELOG entry, for the release to carry) | +| 06 | [Live check in DCS](tickets/06-live-check.md) | 🧑 waiting-human (Zip) | + +## Release notes + +When the feature ships, the release notes poke fun at ED's native Fat Cow, announced on 2024-04-19 and 2024-08-23 and still absent from DCS 2.9.30.28738. diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md new file mode 100644 index 00000000..f45857fa --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -0,0 +1,106 @@ +# 01 — In-game spike: a runtime FARP, its finite stock, its vehicles + +**Status:** 🧑 waiting-human — phase 1 done (2026-10-26), phase 2 (multiplayer client) not run yet · **Type:** HITL (live DCS, Zip) + +## Parent + +[PRD — FEAT-FAT-COW](../PRD.md). Decision 4. + +## Questions + +- **(a)** Does a FARP spawned mid-mission serve refuel/rearm to a player who was already in game? +- **(b)** Is a finite warehouse stock respected, and seen identically by the server and by a client? +- **(c)** Does DCS require a refuel/ammo vehicle in range of an Invisible FARP? + +## Kit + +- `tests/dcs/pilotActive/scenario_fatcow_spike.lua` — pure DCS API, no CTLD. An F10 menu per player group, "Fat Cow spike": 1 Invisible FARP under me · 2 Finite stock (300 kg, 2 Hellfire) · 3 Read stock + my aircraft · 4 Add fuel + ammo trucks · 5 Crates only (barrels + ammo crate) · 6 Remove everything · 7 Add FARP props (fuel depot, ammo dump, barrels, ammo crate) · 8 Fuel depot only · 9 Ammo dump only. Commands 5, 7–9 were added after the first runs (the single-pad variant and a fuel-truck-only command were dropped once moot). Every measurement goes to the screen and to `dcs.log`, prefixed `[FCSPIKE]`. +- `missions/Test_FatCow_Spike.miz` — Caucasus, a flat field 19 km from Senaki and from Kutaisi (no airfield ground crew in reach). Two AH-64D slots (`Apache-1`, `Apache-2`, 80 m apart), ground start with engines running (`TakeOffGroundHot`) on the field, 30 % fuel (342 kg), empty pylons: the spike needs no flying (Zip, 2026-10-26: no stick at hand). Trees at the exact spot are not checked by any file: a slot that explodes at spawn means a tree. The script is embedded and runs at mission start. +- Built with the VMCT `veaf-mission-editor` actions on a blank Caucasus mission (`veaf_libs.blank_mission`): `add_player_slot` ×2 (air start), `set_group_properties` `move_to` ×2 and `edit_route` `set` waypoint 1 to `TakeOffGroundHot` ×2 (`add_player_slot` refuses a ground start off an airfield), `add_startup_script_trigger` (`file_static`). To rebuild after a script change, redo these actions on a fresh blank mission. + +The 300 kg of fuel is below the ~800 kg a 30 % Apache takes to fill up, and the 2 Hellfires below the 4 a rail takes: a cap shows as a partial service. + +## Procedure — phase 1, single player (answers a, c, and b on one machine) + +Press **Print Screen** (DCS screenshot, saved in `Saved Games\DCS\Screenshots`) where marked 📸: the screenshots and `dcs.log` are read afterwards, nothing has to be copied by hand. + +1. DCS main menu → **MISSION** → `Test_FatCow_Spike.miz` → **OK**. If a slot is asked, take **Apache-1**. +2. You are on the ground, engines running. A message `[FCSPIKE] loaded` shows at start. Touch nothing. +3. `\` → **F10 Other** → **Fat Cow spike** → **F1** "1. Invisible FARP under me". Green smoke under you; lines `SPAWNED` and `INITIAL STOCK`. 📸 +4. `\` → **F8 Ground Crew** → **F1 Rearm & Refuel**. The rearm window opens (or not). 📸 Close it. +5. `\` → **F10 Other** → **Fat Cow spike** → **F2** "2. Finite stock". Lines `SET STOCK` and `STOCK READ BACK`. 📸 +6. `\` → **F8 Ground Crew** → **F1 Rearm & Refuel**. 📸 In the window: fuel to **100 %**, and **4 × AGM-114K** on one pylon. Confirm the window. +7. Wait until the ground crew is done (or says it cannot). 📸 +8. `\` → **F10 Other** → **Fat Cow spike** → **F3** "3. Read stock + my aircraft". 📸 +9. **Only if step 6 or 7 refused the service:** `\` → **F10 Other** → **Fat Cow spike** → **F4** "4. Add fuel + ammo trucks", then redo steps 6 to 8. +10. **Only if step 3 printed `getWarehouse() = nil`:** `\` → **F10 Other** → **Fat Cow spike** → **F6** "6. Remove everything", then **F5** "5. Single pad FARP ahead", hover-taxi onto the green smoke 40 m ahead, land, and redo steps 4 to 8. This step needs flight controls: without them, stop at step 9 and report. +11. Quit the mission. Say "phase 1 done". + +## Procedure — phase 2, multiplayer (the client side of b) + +Needs the mission on a dedicated server (the VEAF test server) and Zip joining it as a client: in single player the server and the client are the same process. + +1. Load `Test_FatCow_Spike.miz` on the server. Join it as a client, slot **Apache-1**. +2. Steps 2 to 8 of phase 1. +3. Back to spectators (**Esc** → **Choose slot** → spectators), then take **Apache-1** again: a new birth. The script prints `STOCK AT BIRTH`. +4. `\` → **F8 Ground Crew** → **F1 Rearm & Refuel**. 📸 +5. Quit. The server's `dcs.log` holds the `[FCSPIKE]` lines. + +## Reading the results + +| What is seen | Conclusion | +|---|---| +| Step 3 `INITIAL STOCK` prints numbers | (b) The Invisible FARP has a warehouse: the countryside scene's step 11 comment is wrong. | +| Step 3 prints `getWarehouse() = nil` | No finite stock on an Invisible FARP → step 10; if the single pad has one, decision 2 changes its FARP type (to Zip). | +| Step 4 window opens with nothing to take | (b) The runtime FARP starts empty, as the forum guide says: the cow must stock it, which it does anyway. | +| Step 6–7 serves, without trucks | (a) yes and (c) no vehicle needed: decision 2 holds. | +| Step 6–7 refuses, step 9 serves | (c) DCS needs vehicles: fallback, a vehicle hidden under the cow's fuselage. | +| Step 9 still refuses | (a) a runtime FARP does not serve a player already in game: stop, report to Zip (the RotorOps route, teleporting pre-placed FARPs, becomes the candidate). | +| Step 8: aircraft got ~300 kg and 2 Hellfires, stock 0 | (b) finite stock respected on the server. | +| Step 8: aircraft got full fuel and 4 Hellfires | (b) the stock is not enforced: stop, report to Zip (decision 3). | +| Phase 2: rearm window shows the same quantities as `STOCK NOW` | (b) client and server agree. | +| Phase 2: the client's window shows something else (empty, or unlimited) | (b) no network sync: the stock has to be re-applied on each birth, as VMCT does, and enforcement checked server-side. | + +## Answers + +### Phase 1 — single player, 2026-10-26 (Zip, DCS 2.9.30.28738, no stick: Apache on the ground, engines running) + +From `dcs.log` and Zip's screenshots. + +| Step | Measured | +|---|---| +| 3 | `INITIAL STOCK`: jet fuel 0, every Hellfire 0, inventory empty. `getWarehouse()` is not nil. DCS logs `NO ATC COMM HELIPAD + FCSPIKE_FARP_1:127500000` (a warning, no effect seen). | +| 4 | The Mission Resources window opens, loadout list empty. | +| 5 | `STOCK READ BACK`: jet fuel 300, `AGM_114K` 2, the two other Hellfire names 0. | +| 6–7 (no trucks) | Fuel 100 % + 4 × AGM-114K, OK: nothing happens, no message. | +| 9 (trucks) | Same request: the ground crew answers, works ~30 s, then "Unable to comply". | +| 9 again | Fuel 50 %: "We don't have this in stock". | +| 7 (props, new mission) | Fuel 100 % + 4 × AGM-114K: "Copy", then served; `STOCK NOW` 0, `AIRCRAFT` 634 kg and `AGM_114K` ×2. | +| 8 | `STOCK NOW`: jet fuel 0, `AGM_114K` 0. `AIRCRAFT`: fuel 631 kg / 1 140 kg (342 at spawn), `AGM_114K` ×2, `M230_HEDP M789` ×1 200 (already full). The window shows 56 % and 2 Hellfires on pylon 2. | + +- **(a) Yes.** An Invisible FARP spawned by `coalition.addStaticObject` mid-mission serves a player already in game. +- **(b) Yes, on the server side.** The runtime FARP has a warehouse and starts empty. A finite stock set by script is served until it runs out, then refused: +289 kg for 300 kg in stock (the rest burnt at idle during the test), 2 Hellfires out of 4 asked. A request bigger than the stock is partly served, then answered "Unable to comply". Liquids are in kg. The Hellfire item is `weapons.missiles.AGM_114K`. +- **(c) A supplier is required, but statics are enough: no vehicle.** With the FARP alone the window opens but nothing is served and nothing is said. With an M978 HEMTT Tanker and an M 818 30 m away, it is served. With **statics only** (command 7: `FARP Fuel Depot`, `FARP Ammo Dump Coating`, `barrels_cargo`, `ammo_cargo`, 25–30 m away, no vehicle) the ground crew answers "Copy" and serves: fuel 342 → 634 kg, `AGM_114K` ×2, stock down to 0. Decision 2 holds as is, no hidden vehicle. Then each static alone, on a new FARP with a new 300 kg / 2 Hellfire stock each time: + + | Statics around the FARP | Fuel | Hellfire | + |---|---|---| + | none | no (no answer) | no | + | `barrels_cargo` + `ammo_cargo` (command 5), or each alone | no (no answer) | no | + | **`FARP Fuel Depot` alone** (command 8) | **yes** (+292 kg, stock 0) | **yes** (2, stock 0) | + | `FARP Ammo Dump Coating` alone (command 9) | no ("No can do", stock stays 300) | yes (2 → 4) | + + **The `FARP Fuel Depot` alone supplies both fuel and weapons**; the ammo dump supplies weapons only; cargo statics supply nothing. The cow scene therefore needs the fuel depot and nothing else as a supplier: no ammo dump, no tent, no vehicle (Zip dislikes the bunker and the tent). Not measured: the range (25–30 m was used). +- Where the rule lives: not in DCS's Lua files (`db.Units.lua` gives the M978 and M 818 only generic attributes, and the FARP statics none), nor as text in the clear DLLs of `bin/`; it is in the compiled engine, probably `Inter.dll` or `WorldGeneral.dll`. Measuring in game was preferred over decompiling. +- The countryside scene's step 11 comment ("Invisible FARP airbases … return nil for getWarehouse()") is wrong, measured. + +No answer kills the design. + +### Phase 2 — multiplayer client + +Not run yet: needs the mission on a dedicated server with Zip joining it as a client. + +## Acceptance criteria + +- [x] (a), (b) on one machine, (c) answered from `dcs.log` and the screenshots, in this ticket and the PRD. +- [ ] (b) client side answered by phase 2, or marked not measured with the reason. +- [x] If an answer kills the design, reported to Zip before ticket 02 is written. diff --git a/.backlog/FEAT-FAT-COW/tickets/02-core-stock-and-scene.md b/.backlog/FEAT-FAT-COW/tickets/02-core-stock-and-scene.md new file mode 100644 index 00000000..bf71f541 --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/02-core-stock-and-scene.md @@ -0,0 +1,22 @@ +# 02 — Core: a cow's stock, its scene, deploy and pack + +**Status:** ✅ done · **Type:** AFK + +## Parent + +[PRD — FEAT-FAT-COW](../PRD.md). Decisions 2, 3; ticket 01 answers. + +## What to build + +- A `CTLDFatCow` entity (Manager + Entity, `src/core/class.lua`) holding a **stock**: fuel in kg and weapons by warehouse item name (`weapons.missiles.AGM_114K`, the 30 mm item), and a state: `stowed` (on board), `deploying`, `deployed`. +- A `CTLDFatCowManager` singleton keyed by cow unit name. +- A **Fat Cow scene** (`src/scenes/CTLD_fatCowScene.lua`): an Invisible FARP at the cow, a `FARP Fuel Depot` 25 m behind it (the only supplier needed, ticket 01), a few ammo crates and barrels as dressing, 3 standing infantry. No vehicle, no tent, no ammo dump, no bunker. +- **Deploy**: plays the scene; on its last step, sets the FARP warehouse to the stock (jet fuel = stock fuel, every other liquid 0, each stocked item to its count). +- **Pack**: reads the FARP warehouse back into the stock (what the players took is gone), then removes the scene. +- **Re-sync**: on every player birth, each deployed cow re-writes its warehouse with the values the server holds (read, then write): ticket 01 did not measure the client side, and VMCT re-applies on birth for that reason. Costs nothing if DCS already syncs. +- Fix the countryside scene's step 11 comment: an Invisible FARP does have a warehouse (measured). + +## Acceptance criteria + +- [x] Busted: deploy writes the stock into the warehouse; pack reads the remainder back; a stock emptied by players reads back as empty; re-sync writes back what the server holds; the scene spawns no vehicle and includes a `FARP Fuel Depot`. +- [x] Scene assets pass the design-time asset gate. diff --git a/.backlog/FEAT-FAT-COW/tickets/03-piloted-cow.md b/.backlog/FEAT-FAT-COW/tickets/03-piloted-cow.md new file mode 100644 index 00000000..db7cce7a --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/03-piloted-cow.md @@ -0,0 +1,21 @@ +# 03 — Piloted cow: cow crates in a CH-47F and the F10 commands + +**Status:** ✅ done · **Type:** AFK + +## Parent + +[PRD — FEAT-FAT-COW](../PRD.md). Decision 1, piloted mode. + +## What to build + +- Cow crates in the CTLD crate catalogue, requestable in a logistic zone by a cow-capable type (`CH-47Fbl1` by default): + - **fuel tank**: one 800 gal ERFS tank, 2 400 kg of fuel, that mass in the hold; + - **Hellfire**: a crate of Hellfires (count and mass configurable); + - **30 mm**: a crate of 30 mm rounds (count and mass configurable). +- F10, once landed with cow crates on board: **Deploy Fat Cow FARP** — the stock is the sum of the cow crates on board, which stay on board (the fuel stays in the cow, as in reality). +- F10, deployed: **Pack Fat Cow FARP** — reads the remaining stock back; the cow crates on board are reduced to what is left (fuel mass in the hold goes down; an emptied crate disappears). +- Taking off while deployed packs automatically. + +## Acceptance criteria + +- [x] Busted: stock = sum of the cow crates on board; pack reduces the crates and the hold mass to what is left; the deploy command needs the cow on the ground and at least one cow crate; take-off packs. diff --git a/.backlog/FEAT-FAT-COW/tickets/04-ai-cow.md b/.backlog/FEAT-FAT-COW/tickets/04-ai-cow.md new file mode 100644 index 00000000..782c32cf --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/04-ai-cow.md @@ -0,0 +1,20 @@ +# 04 — AI cow: requested by a flight, escorts it, serves it, goes home + +**Status:** ✅ done · **Type:** AFK + +## Parent + +[PRD — FEAT-FAT-COW](../PRD.md). Decision 1, AI mode. + +## What to build + +- F10 **Request a Fat Cow** for a player of a flight, only on the ground inside a friendly CTLD logistic zone. CTLD spawns a CH-47 from the configured template next to the zone, with the default AI stock (full fuel, 3 × 800 gal ≈ 7 200 kg, and 6 Hellfires). +- The cow **follows** the flight (DCS `Follow` task on the requesting group). +- When the flight's leader lands, the cow gets a `Land` task (a task on a turning point, not a `Land` waypoint: VMCT measured that a `Land` waypoint goes to the nearest airfield) on open ground near the leader; **60 s** after its own landing, it deploys. +- When the flight's leader takes off, the cow packs, takes off and follows again. +- Empty stock, or F10 **Release the Fat Cow**: it packs if deployed, flies back to its logistic zone, lands and despawns. +- A **mission-placed** AI CH-47 is a cow when a config entry names it with the flight it escorts (the VMCT contract, see the PRD). + +## Acceptance criteria + +- [x] Busted: request refused outside a logistic zone or in the air; one cow per flight; land → deploy after the delay; take-off → pack + follow; empty → home + despawn; release → home + despawn; a configured mission cow is picked up at start. diff --git a/.backlog/FEAT-FAT-COW/tickets/05-config-docs-release.md b/.backlog/FEAT-FAT-COW/tickets/05-config-docs-release.md new file mode 100644 index 00000000..b72928d4 --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/05-config-docs-release.md @@ -0,0 +1,17 @@ +# 05 — Config, VMCT contract doc, CHANGELOG and release notes + +**Status:** ✅ done · **Type:** AFK + +## Parent + +[PRD — FEAT-FAT-COW](../PRD.md). + +## What to build + +- One `fatCow` block in `src/CTLD_config.yaml` + schema (EN/FR labels) + `configVersion` bump + catalogue shape + regenerated oracles. +- `docs/pilot/` and `docs/mission-maker/` pages, EN + FR: flying a cow, requesting one, and the contract VMCT relies on (config keys, how a mission-placed AI CH-47 is declared a cow). +- `CHANGELOG.md` `[Unreleased]`, and a release-notes line poking fun at ED's native Fat Cow, announced on 2024-04-19 and 2024-08-23 and absent from DCS 2.9.30.28738. + +## Acceptance criteria + +- [x] `test_catalogue_shape.py` and `config_spec.lua` green; docs build; CHANGELOG guard green. diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md new file mode 100644 index 00000000..4873a6ed --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -0,0 +1,111 @@ +# 06 — Live check in DCS + +**Status:** 🧑 waiting-human · **Type:** HITL (live DCS, Zip) + +## Parent + +[PRD — FEAT-FAT-COW](../PRD.md). + +## What to check + +- Piloted: a CH-47F loads cow crates, lands, deploys; an Apache landed next to it is served up to the stock; pack; the hold mass went down. +- AI: an Apache requests a cow in a logistic zone, takes off, the cow follows, both land, the FARP deploys after 60 s, the Apache is served, takes off, the cow packs and follows; release sends it home. +- The range: how far from the cow an Apache can land and still be served (ticket 01 used 0 m). +- The 30 mm crate: whether `weapons.shells.M230_HEDP M789` is the warehouse item the Apache's gun reload draws from. +- The AI cow's DCS behaviour, which the unit tests only mock: the `Follow` task on a player flight, the `Land` task next to the leader on open ground, the take-off when re-tasked, the despawn at home. +- If a second player is available: the client side of ticket 01 (b). + +## Kit + +`Test_FatCow_LiveCheck.miz`, in `Saved Games\DCS\Missions` on Zip's machine and **not in the repository**: it embeds a built `CTLD.lua`, which `CLAUDE.md` forbids committing (and `CTLD_DEV_ROOT` loading needs a de-sanitised `os`, which this machine does not have). +Rebuild it after any `src/` change, with the VMCT `veaf-mission-editor` actions on a blank Caucasus mission (`veaf_libs.blank_mission`): + +- `add_trigger_zone` `LGZ_cowbase_B` at x -290000, y 665150, radius 400 m: a logistic zone on a flat field 19 km from Senaki and from Kutaisi, so no airfield warehouse serves anyone; +- `add_player_slot` + `edit_route` (waypoint 1 → `TakeOffGroundHot`), all inside the zone, engines running: `Cow-1` (CH-47Fbl1) at y 665000; `Gunfighter` (AH-64D_BLK_II, 30 % fuel, empty pylons) 60 m east of it, `Apache-150m` 150 m east, `Apache-300m` 300 m east; +- `add_air_group` + `edit_route`: `Hawk`, an AI AH-64D 3 km south, taking off hot, landing (Land task) at x -291000, y 666500 for 300 s, then at x -292500, y 667500 for good; `Fat Cow Hawk`, a late-activated AI CH-47Fbl1 at y 665650 in the zone, taking off hot; +- `add_startup_script_trigger`: `file_static` of the built `CTLD.lua`, then `inline`: `CTLDConfig.get().settings["fatCowAICows"] = { { group = "Fat Cow Hawk", escort = "Hawk" } }` and `CTLDFatCowManager.getInstance():_activateMissionCows()` (the mission-placed cow, the VMCT contract, without a full config snapshot). + +The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.log`) while the piloted checks go on. + +### Server variant (multiplayer, 2026-10-26) + +`VEAF_OpenTraining_Syria_FatCowTest_ICAO_LTAG_20261026.miz` (Saved Games\DCS\Missions, not in any repository): the Syria Open Training v6 at its committed state, rebuilt in a copy (`D:\tmp\OT-Syria-FatCow`) with `published/src/scripts/community/CTLD.lua` replaced by this branch's build (`ctld.VERSION` `2.0.0-rc13-fatcow-d8da3839`, so develop's fixes since rc13 are in too) and the `fatCow*` block added to `ctld-config.yaml` (catalogue 2.3.0). Mission name `VEAF_OpenTraining_Syria_FatCowTest_ICAO_LTAG`, so it never replaces the production file. CH-47F and AH-64D dynamic slots at Hatay and FARP Reyhanli; every airfield and FARP ammo dump is a CTLD logistic zone. On a server, Zip is a client: the client side of ticket 01 (b) is measured by the same checks (the rearm window shows the cow's stock, served up to it). + +### First server run — 2026-10-26 afternoon (Zip, Privé 1, client) + +- Piloted CH-47F near Akrotiri (4.5 km from the airfield): deployed with 2 400 kg; an Apache landed at the FARP was refuelled and rearmed. A client on a dedicated server is served by a cow FARP: ticket 01 (b) client side answered in practice. +- The pilot changing slot leaves the FARP in place: kept on purpose (decision 4a). +- Findings, all addressed in the second build (`-v2`, `ctld.VERSION` `2.0.0-rc13-fatcow-5cd3286a`): loading one crate at a time is tedious (loadouts); the AI cow reported an empty stock until it landed (bug: it read crates it does not have); its request was refused outside a logistic zone (now from anywhere, from the nearest zone, with an ETA); it packed and left whenever the leader hopped onto its FARP (a player flight now commands it: Land here, Take off, Hold here, Join us); no way to know what the FARP you are at still holds (FARP status). +- Measurement for "FARP status on every FARP": the Open Training's mission FARPs (filled by VMCT) read jet fuel 1000000 and AGM_114K 1000000 through `getLiquidAmount` / `getItemCount`, while `getInventory` lists 0 weapon types. A follow-up can show such a warehouse as "full". + +### Evening run — 2026-10-26 (Privé 1, Zip, Griffin, Paluche): findings + +1. **The AI cow exploded on take-off** (21:06:29.9), under a second after its FARP was packed: the Invisible FARP under it was destroyed. Hypothesis, not measured: an AI aircraft sitting on an airbase dies when the airbase is destroyed (Zip's piloted CH-47 survived the same pack at 21:06:16). +2. **A packed FARP stays on the F10 map**: destroying a heliport static leaves its airbase. One ghost per deployment (`CS_FARP-55`, `-61`, `-67`, `-73`). + Proposed for 1 and 2: one FARP per cow, never destroyed: packing removes the depot, dressing and crew and empties the warehouse; the next deployment re-adds the heliport under the same name at the new spot (RotorOps PERKS moves its Fat Cow FARPs this way). +3. **The cow's FARP became unlimited**: VMCT's `veafGrass.onBirth` refills every FARP warehouse (50 000 kg of each liquid, ~5 000 of every weapon) on every player birth; packs read 50 035 and 50 055 kg. Proposed: the cow's FARP named `CTLD_…`, VMCT's refill skips `CTLD_` FARPs (VMCT PR), and CTLD restores its own count if fuel or Hellfires go up. +4. **"Load: Max weapons + max fuel" refused on the French server**: CTLDConfig translates every config `desc`, so the crate is named "Réservoir Fat Cow (800 gal)" while the loadout names it in English. Proposed: look the crate up by its translated name, with a test in French. +5. **No CSAR menu in the CH-47F**: VMCT's `CSAR.lua` lists no `CH-47Fbl1` in `csar.aircraftType`, in every VMCT mission. Proposed: one line in the same VMCT PR. +6. **Radar missiles** (Zip): the cow should also carry AGM-114L (Longbow, radar). + +Fixed on 2026-10-27, in this PR (Zip's lot 1): +- 1 and 2: one FARP per cow (`CTLD_FatCow_FARP_`), never destroyed: packing removes the depot, dressing and crew and empties the warehouse; the next deployment adds the heliport again under the same name, where the cow is. To measure in game: that adding a static under an existing name moves the FARP and leaves no ghost. +- 3, CTLD side: a warehouse count that went up is put back to the cow's own, weapons the cow does not carry are removed, and the warehouses are written again 2 s after a birth too. The VMCT side (GRASS skipping `CTLD_` FARPs) is a separate VMCT PR. +- 4: a loadout no longer names crates: it lists fuel kg and items, and CTLD splits it into the crates that carry them. +- 5: VMCT side, the same VMCT PR. +- 6, and Zip's loadout profiles: four loadouts, each two 800 gal tanks (4 800 kg) with 40 Hellfires (25 laser, 15 radar) + 2 400 rounds, 60 (38 + 22) + 1 200, 40 laser + 2 400, 60 laser + 1 200 (crates of 20 laser, 20 radar, 1 200 rounds); an AI cow flies with 80 % internal fuel and carries the loadout the requesting flight picks (submenu). Masses from the DCS data mine: AGM-114K 45.3 kg, AGM-114L (warehouse name `weapons.missiles.AGM_114`, to check in game) 49 kg. +- F10 menus regrouped: a piloted cow's **Load** and **Request a crate** submenus, an AI request's loadout submenu. + +## Procedure + +**Print Screen** where marked 📸. Nothing to copy: `dcs.log` and the screenshots are read afterwards. + +### A — Piloted cow and range (no flying) + +1. **MISSION** → `Test_FatCow_LiveCheck.miz` → **OK**, slot **Cow-1**. + The message `[Fat Cow live check] CTLD loaded…` shows. +2. `\` → **F10 Other** → **CTLD** → **Fat Cow** → **Request a crate** → **Fat Cow fuel tank (800 gal)**. 📸 + Then **Request a crate** → **Fat Cow laser Hellfires (x20)**. +3. `\` → **F10 Other** → **CTLD** → **Crate Commands** → **Load Crate** → the fuel tank; again for the Hellfires. + If **Load Crate** is not there, load them with the DCS cargo window. +4. `\` → **F10 Other** → **CTLD** → **Fat Cow** → **Fat Cow stock**: expected 2400 kg of fuel, laser Hellfire x20. 📸 +5. `\` → **F10 Other** → **CTLD** → **Fat Cow** → **Deploy the Fat Cow FARP**. + Wait for `Fat Cow FARP open: …` (a few seconds). **F2** (external view) to look at the layout. 📸 +6. **Esc** → **Choose slot** → **Gunfighter** (60 m away). + `\` → **F8 Ground Crew** → **F1 Rearm & Refuel** → **FUEL 100 %**, **4 × AGM-114K** on one pylon → **OK**. Wait for the ground crew. 📸 +7. Same as 6 with **Apache-150m**, then **Apache-300m**: fuel only (the Hellfires are gone). + Note for each what the ground crew says: "Copy", "Unable to comply", or nothing. +8. Back to **Cow-1**: `\` → **F10 Other** → **CTLD** → **Fat Cow** → **Fat Cow stock**. 📸 + Then **Pack the Fat Cow FARP**. 📸 +9. Quit. + +### B — Requested AI cow (needs flying) + +1. **MISSION** → `Test_FatCow_LiveCheck.miz` → **OK**, slot **Gunfighter**. +2. `\` → **F10 Other** → **CTLD** → **Fat Cow** → **Request a Fat Cow** → a loadout. A CH-47 comes from the nearest logistic zone. +3. Take off, fly 2 km over open fields, land in the open. The cow should land about 120 m to your left and announce `Fat Cow FARP open` one minute after touching down. 📸 +4. Rearm and refuel as in A6. 📸 +5. Take off: the cow should pack and follow. Land again somewhere else: it lands next to you again. +6. `\` → **F10 Other** → **CTLD** → **Fat Cow** → **RTB**: it flies back to where it appeared and disappears. +7. Quit. + +### C — Lot 1 fixes (2026-10-27), on the server with the next build + +1. CH-47F in a logistic zone: **Fat Cow** → **Load** → **40 Hellfires (25 laser, 15 radar), 2400 rounds**: 7 crates in the hold, the message lists 4800 kg of fuel, laser Hellfire x25, radar Hellfire x15, 30 mm rounds x2400. +2. Land elsewhere, **Deploy the Fat Cow FARP**; an Apache rearms with **radar** Hellfires (AGM-114L) and the gun: served, or "We don't have this in stock" (then the warehouse name `weapons.missiles.AGM_114` or the 30 mm name is wrong: tell the session). +3. Another player takes a slot, then **FARP status** at the cow: the stock has not jumped to 50 000 kg (VMCT's refill is undone). +4. Take off (the FARP packs), land 2 km further, deploy again: on the F10 map, **one** FARP for the cow, now at the new spot, and no new one at the old spot. +5. Apache: **Request a Fat Cow** → **60 laser Hellfires, 1200 rounds**; **Land here**, wait for the FARP, then **Take off**: the cow lifts off and does **not** blow up. +6. Ka-50: **Request a Fat Cow** → **Russian** → **40 Vikhr, 920 rounds (Ka-50)**; rearm with Vikhr and the gun at its FARP. Mi-24P: same with **32 Ataka, 500 rounds (Mi-24P)**. Each refused item ("We don't have this in stock") is a warehouse name to correct: tell the session which. + +## Reading the results + +| What is seen | Conclusion | +|---|---| +| A6: "Copy", fuel and 4 Hellfires served | Piloted cow works; 60 m is in range. | +| A7: "Copy" at 150 m but nothing at 300 m (or the other way) | The FARP's service range lies in between: the docs say to land within it. | +| A8: stock = 2400 minus what the three Apaches took; after pack the CH-47 is lighter | Pack reads the remainder back and the hold mass follows. | +| A5: depot, crates or soldiers under the rotor, or no `FARP open` message | Layout or scene problem: screenshot needed. | +| B3: the cow lands far away, hovers, or goes to an airfield | The AI `Land` task does not behave as VMCT measured: the cow's landing needs rework. | +| B5: the cow stays on the ground after the take-off | The re-task to `Follow` does not lift a landed AI helicopter. | +| `dcs.log`: `cow 'Fat Cow Hawk…' deployed`, then `packed` | The mission-placed cow (VMCT contract) works without a player. | diff --git a/.backlog/README.md b/.backlog/README.md index 9df1252b..1bca3044 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,6 +14,7 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| +| [`FEAT-FAT-COW`](FEAT-FAT-COW/PRD.md) | merged (PR #273) — 06 waiting-human (live check with the squadron, Zip) — 01 spike ✅ (a runtime Invisible FARP serves, enforces a finite stock, and needs only a FARP Fuel Depot as supplier), 02-05 ✅ | A CH-47 that lands in the field and becomes a forward refuel/rearm point (AI escort of a flight, or player-flown with cow crates), stocked with what it carries. | `feature/fat-cow` | | [`FIX-I18N-TRANSLATE-ALIAS`](FIX-I18N-TRANSLATE-ALIAS/PRD.md) | merged (PR #269) — 1 ticket ([JTAC through tr, guard sees the alias](FIX-I18N-TRANSLATE-ALIAS/tickets/01-jtac-through-tr-and-guard-sees-the-alias.md)) | The 16 JTAC radio keys reach the four dictionaries; the JTAC module calls ctld.tr and the generator also recognises the alias. | | [`FIX-FARP-PACK-FALSE-SUCCESS`](FIX-FARP-PACK-FALSE-SUCCESS/PRD.md) | merged (PR #268) — 1 ticket ([report the crates created](FIX-FARP-PACK-FALSE-SUCCESS/tickets/01-report-the-crates-created.md)) | Packing a FARP now reports a pack that created no crate, or only some, instead of always announcing a success. | | [`FIX-UNPACK-FALSE-SUCCESS`](FIX-UNPACK-FALSE-SUCCESS/PRD.md) | merged (PR #267) — 1 ticket ([announce success only on true](FIX-UNPACK-FALSE-SUCCESS/tickets/01-announce-success-only-on-true.md)) | The Unpack Crate click no longer announces a success when a JTAC limit or a missing spawn position produced nothing. | diff --git a/.luacheckrc b/.luacheckrc index 60096470..d37e5be0 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -85,6 +85,8 @@ globals = { "CTLDStaticWatcher", "CTLDDCSEventBridge", "CTLDCoreManager", + "CTLDFatCow", + "CTLDFatCowManager", } -- Per-file overrides diff --git a/CHANGELOG.md b/CHANGELOG.md index a616533d..2ee4c9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ Versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — Fat Cow: a CH-47 that lands in the field and becomes a forward refuel and rearm point (FEAT-FAT-COW) + +ED announced a native Fat Cow on 2024-04-19, and again on 2024-08-23; DCS 2.9.30.28738 still has none, so CTLD brings its own while the Chinooks wait. + +- A **player-flown CH-47F** loads a loadout in a logistic zone (**Load**, `fatCowLoadouts`, split into crates straight into the hold) or one crate at a time (**Request a crate**: an 800 gal ERFS tank of 2 400 kg of jet fuel, 20 laser Hellfires, 20 radar Hellfires, 1 200 rounds of 30 mm), lands, and **Deploy the Fat Cow FARP** from F10 → CTLD → Fat Cow. + The default loadouts all carry two tanks (4 800 kg): at the top, for the Apache, 40 Hellfires (25 laser, 15 radar) and 2 400 rounds, 60 Hellfires (38 laser, 22 radar) and 1 200 rounds, 40 laser Hellfires and 2 400 rounds, or 60 laser Hellfires and 1 200 rounds; under **Russian**, Vikhr and 2A42 rounds for the Ka-50, Ataka or Shturm and GSh-30 rounds for the Mi-24P; under **Mixed**, two Apache and Russian mixes (`menu` field of a loadout). + 6 400 to 8 200 kg, what a CH-47F lifts with 80 % of its internal fuel; the cow is a CH-47 for everyone, whatever they fly. + The crates stay in the hold; **Pack the Fat Cow FARP**, or taking off, puts what was not used back into them, lightens the hold and removes the emptied crates. **Unload the Fat Cow crates** empties the hold. +- Any other player can **Request a Fat Cow** from anywhere, picking one of the same loadouts: an AI CH-47 comes from the nearest friendly logistic zone on land, with its distance and an estimated joining time, and follows the flight. + The flight commands it from F10 and it does nothing on its own: **Land here** (FARP open 60 s after touchdown, `fatCowDeployDelay`), **Take off**, **Hold here**, **Join us**, **RTB** (back to base, despawn). **Fat Cow status** gives its phase, distance, bearing, joining time and stock. + It flies with 80 % of its internal fuel. +- **FARP status** tells what the Fat Cow FARP you are at still holds, whoever it belongs to. +- A cow placed in the mission (a late-activated helicopter group) escorts a named flight when declared in `fatCowAICows` (`{ group, escort, loadout }`): the contract the VEAF Mission Creation Tools rely on. With an AI flight it lands and takes off with the leader on its own; a client flight commands it like a requested one. +- The FARP is an Invisible FARP, a FARP Fuel Depot, a few crates and three soldiers: no vehicle, no tent, no ammo dump. + A FARP left open by a pilot who left the slot stays until a pilot takes that slot again, then closes, emptied; a deployment whose FARP or fuel depot cannot be added stows the cow and says so. + Its warehouse holds the cow's stock and nothing else, and DCS serves it until it runs out ("Unable to comply" once a request outruns it). + Each cow has one FARP (`CTLD_FatCow_FARP_`), emptied when packed and added again at the next deployment, never destroyed: a destroyed heliport stayed on the F10 map, and an AI cow blew up when its FARP was destroyed under it (server run, 2026-10-26). + All of this was measured in DCS first (FEAT-FAT-COW ticket 01): a FARP spawned mid-mission serves a player already in game, enforces a finite stock, and needs a supplier in range, which the FARP Fuel Depot alone is. +- The warehouse is written again every 10 s and whenever a player joins, at once and two seconds later, with what the server holds: whether DCS shares a scripted warehouse with clients was not measured. + A quantity that went up was refilled by another script (VMCT refills every FARP on each player birth, which made a cow unlimited): CTLD keeps the cow's count and removes any weapon the cow does not carry. +- New settings `fatCowEnabled`, `fatCowTypes`, `fatCowCrates`, `fatCowLoadouts`, `fatCowDeployDelay`, `fatCowAIType`, `fatCowAICows`; catalogue version 2.3.0. +- A part-used Fat Cow crate weighs what it still holds (`getLoadedCrateWeight` reads `metadata.massKg`), and Fat Cow crates are never offered to Unpack Crate. +- The countryside FARP scene's comment claiming an Invisible FARP has no warehouse is corrected: it has one, measured. +- FR/ES/KO texts are a proposal for review. Docs: pilot and mission-maker **Fat Cow** pages, EN + FR. + ### Fixed — the JTAC radio messages are translated, and the guard sees the `ctld.i18n_translate` alias (FIX-I18N-TRANSLATE-ALIAS) - The JTAC radio messages were written through `ctld.i18n_translate`, an alias of `ctld.tr` that the dictionary generator, the `i18n-guard` diff --git a/dev/roadmap.md b/dev/roadmap.md index d4b30cd2..422b1d33 100644 --- a/dev/roadmap.md +++ b/dev/roadmap.md @@ -285,3 +285,13 @@ Quatre tests (`test_install`, `test_inject_into_miz`) échouent en local quand ` été réexportée par ctld-tools (elle porte alors déjà un moteur et des sons, `replacedPrevious` vaut vrai), alors qu'ils passent contre la version commitée utilisée par le CI. Idée : les faire partir d'une copie « pristine » comme le fait déjà la fixture `pristine_miz`, ou d'une fixture minimale indépendante de la mission de dev. + + + +## Fat Cow — Mi-8 piloté en « petite cow » (lot 3, optionnel) + +Le Mi-8MTV2 pilotable comme cow : 13 000 kg max, 8 866 kg à vide, 1 929 kg de carburant, soit 2 591 kg de charge utile à 80 % de carburant. +Assez pour un ou deux hélicos (par exemple 1 400 kg de carburant et 20 Vikhr) : fidèle au réel, où le Mi-8 ravitailleur emporte des réservoirs de convoyage d'environ 900 L. +Demande un réservoir plus petit que celui de 800 gal du CH-47 et son entrée dans `fatCowTypes` / `capabilitiesByType`. diff --git a/docs/mission-maker/configuration.fr.md b/docs/mission-maker/configuration.fr.md index 0cf0d787..b63f2067 100644 --- a/docs/mission-maker/configuration.fr.md +++ b/docs/mission-maker/configuration.fr.md @@ -185,6 +185,7 @@ aléatoire de 90–120 % de `SOLDIER_WEIGHT`, plus l'équipement et le matériel | `farpTroopPickupRadius` | `150` | Rayon (m) dans lequel les troops peuvent embarquer à une FARP | | `fobDestructionThreshold` | `0.5` | Fraction d'objets de scène détruits avant qu'un FOB soit considéré perdu (0.0–1.0) | | `enableFARPRepack` | `true` | Autorise les joueurs à pack une scène FARP déployée de nouveau en crate pour redéploiement | +| `fatCowEnabled` | `true` | Fat Cow : un CH-47 qui ouvre une FARP approvisionnée avec ce qu'il transporte — voir [Fat Cow](fat-cow.md) pour ce réglage et les autres `fatCow*` | !!! note Le nombre de crate nécessaires pour construire un FOB n'est **pas** un réglage global — il diff --git a/docs/mission-maker/configuration.md b/docs/mission-maker/configuration.md index 02fcdf16..a4053db0 100644 --- a/docs/mission-maker/configuration.md +++ b/docs/mission-maker/configuration.md @@ -179,6 +179,7 @@ randomised 90–120 % of `SOLDIER_WEIGHT`, plus kit and role-specific gear. | `farpTroopPickupRadius` | `150` | Radius (m) within which troops may board at a FARP | | `fobDestructionThreshold` | `0.5` | Fraction of scene objects destroyed before a FOB is considered lost (0.0–1.0) | | `enableFARPRepack` | `true` | Allow players to pack a deployed FARP scene back into crates for redeployment | +| `fatCowEnabled` | `true` | Fat Cow: a CH-47 that opens a FARP stocked with what it carries — see [Fat Cow](fat-cow.md) for this and the other `fatCow*` settings | !!! note The number of crates required to build a FOB is **not** a global setting — it comes from diff --git a/docs/mission-maker/ctld-tools.fr.md b/docs/mission-maker/ctld-tools.fr.md index c09888da..e297396c 100644 --- a/docs/mission-maker/ctld-tools.fr.md +++ b/docs/mission-maker/ctld-tools.fr.md @@ -173,7 +173,7 @@ existante) : pour ne rien perdre par accident. ### Quand CTLD est mis à jour { #when-ctld-is-updated } -CTLD estampille une **version de catalogue** sur sa configuration (`2.2.0` aujourd'hui). Elle ne change que +CTLD estampille une **version de catalogue** sur sa configuration (`2.3.0` aujourd'hui). Elle ne change que lorsque CTLD gagne un réglage ou un champ, pas à chaque release. L'en-tête de l'outil affiche deux versions : **Version de la config**, celle pour laquelle votre configuration ouverte a été écrite, et **Catalogue de l'outil**, celle que porte cette copie de l'outil, mise en évidence quand elles diffèrent. diff --git a/docs/mission-maker/ctld-tools.md b/docs/mission-maker/ctld-tools.md index f572cab9..0723ba80 100644 --- a/docs/mission-maker/ctld-tools.md +++ b/docs/mission-maker/ctld-tools.md @@ -159,7 +159,7 @@ different: it cannot be removed, so CTLD falls back to its default and says so a ### When CTLD is updated -CTLD stamps a **catalogue version** on its configuration (`2.2.0` today). It changes only when CTLD gains a +CTLD stamps a **catalogue version** on its configuration (`2.3.0` today). It changes only when CTLD gains a setting or a field, not at every release. The tool's header shows two versions: **Config version**, the one your opened configuration was written against, and **Tool catalogue**, the one this copy of the tool carries, highlighted when they differ. diff --git a/docs/mission-maker/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md new file mode 100644 index 00000000..d6d26817 --- /dev/null +++ b/docs/mission-maker/fat-cow.fr.md @@ -0,0 +1,136 @@ +# Fat Cow + +Une Fat Cow est un CH-47 qui se pose sur le terrain et ouvre une FARP approvisionnée avec ce qu'il transporte. +Le côté pilote est décrit dans [Fat Cow (pilotes)](../pilot/fat-cow.md) ; cette page couvre les réglages et la façon de placer une cow dans une mission. + +## Ce que CTLD installe + +Quand une cow se déploie, CTLD joue la scène **Fat Cow FARP** à la cow : + +| Objet | Où | Rôle | +|---|---|---| +| FARP invisible | à la cow | l'aérodrome dont l'entrepôt contient le stock de la cow | +| FARP Fuel Depot | 25 m derrière la rampe | le fournisseur dont les équipes au sol ont besoin | +| 2 caisses de munitions, 1 lot de fûts | 20 m derrière, à droite | décor, jamais chargeables | +| 3 soldats | 25 m derrière, à gauche | en attente | + +Pas de véhicule, pas de tente, pas de dépôt de munitions. +C'est mesuré dans DCS 2.9.30.28738 : une FARP créée en cours de mission sert un joueur déjà en jeu, mais seulement avec un fournisseur à portée, et le FARP Fuel Depot suffit à lui seul pour le carburant et les armes (camions citernes, camions de munitions et dépôt de munitions sont inutiles ; caisses et fûts ne fournissent rien). + +Chaque cow a sa propre FARP, nommée `CTLD_FatCow_FARP_`. +Le repli retire le dépôt, le décor et l'équipe et vide l'entrepôt de la FARP, mais laisse la FARP elle-même : un héliport détruit en cours de mission reste sur la carte F10, et une cow IA posée sur sa FARP a explosé quand la FARP a été détruite sous elle. +Le déploiement suivant ajoute de nouveau la FARP sous le même nom, là où se trouve la cow. +Il reste donc une FARP vide sur la carte là où chaque cow s'est déployée en dernier ; sans fournisseur à côté, elle ne sert rien. + +L'entrepôt de la FARP reçoit le stock de la cow et rien d'autre : du carburant jet, et chaque munition que la cow transporte. +DCS le distribue jusqu'à épuisement. +Quand un joueur rejoint le serveur, CTLD réécrit l'entrepôt de chaque cow ouverte, tout de suite puis deux secondes plus tard, avec les quantités que le serveur détient : on n'a pas mesuré si DCS partage avec les clients un entrepôt modifié par script. +CTLD les relit et les réécrit aussi toutes les 10 secondes. +Une quantité qui a augmenté a été remplie par un autre script (les VEAF Mission Creation Tools remplissent toutes les FARP quand un joueur arrive) : CTLD garde le compte de la cow et le remet, et retire toute arme que la cow ne transporte pas. +Ce qui a été pris dans les quelques secondes avant un tel remplissage peut revenir, et les munitions qu'un hélicoptère rend en se réarmant ne s'ajoutent pas au stock de la cow. +Une FARP de cow qui n'est pas ouverte est vidée aux mêmes moments. +La FARP d'une cow IA rentrée à la base sert à la suivante : la carte ne gagne pas une FARP vide à chaque cow demandée. + +## Réglages + +Tous dans la famille `fob` de la configuration. + +| Réglage | Défaut | Rôle | +|---|---|---| +| `fatCowEnabled` | `true` | Le menu F10 **Fat Cow** et les caisses cow. | +| `fatCowTypes` | `[CH-47Fbl1]` | Types qu'un joueur peut piloter en cow. Chacun doit aussi être un transport dans `capabilitiesByType`. | +| `fatCowCrates` | réservoir ; Hellfire et 30 mm (Apache) ; Vikhr et obus de 2A42 (Ka-50) ; Ataka, Shturm et obus de GSh-30 (Mi-24P) | Les caisses dans lesquelles une cow pilotée emporte son stock (voir plus bas). | +| `fatCowLoadouts` | quatre lots Apache, quatre russes et deux mixtes, chacun avec 4 800 kg de carburant | Ce qu'emporte une cow : chargé par une cow pilotée, choisi par une patrouille qui demande une cow IA (voir plus bas). | +| `fatCowDeployDelay` | `60` | Secondes qu'une cow IA passe au sol avant que sa FARP ouvre. | +| `fatCowAIType` | `CH-47Fbl1` | Type de l'hélicoptère amené quand une patrouille demande une cow. Vide, **Demander une Fat Cow** disparaît. | +| `fatCowAICows` | `[]` | Cows placées dans la mission (voir plus bas). | + +### Lots de chargement + +Chaque entrée de `fatCowLoadouts` est ce qu'emporte une cow : du carburant jet en kg, et des articles d'entrepôt DCS avec leur nombre. + +```yaml +fatCowLoadouts: +- desc: 40 Hellfires (25 laser, 15 radar), 2400 rounds + fuelKg: 4800 # deux réservoirs de 800 gal + items: + - item: weapons.missiles.AGM_114K # Hellfire laser + count: 25 + - item: weapons.missiles.AGM_114 # Hellfire radar (AGM-114L) + count: 15 + - item: weapons.shells.M230_HEDP M789 # 30 mm + count: 2400 +``` + +Une cow pilotée les trouve sous **Charger**, dans une zone logistique ; une patrouille qui demande une cow IA en choisit un sous **Demander une Fat Cow**. +Un lot avec `menu: russian` ou `menu: mixed` apparaît dans le sous-menu **Russes** ou **Mixtes**, un lot sans `menu` en tête. +La cow est toujours un CH-47 (`fatCowAIType`), quel que soit le lot et quel que soit l'appareil du demandeur : c'est le module que les joueurs pilotent en cow. + +Les lots par défaut emportent tous deux réservoirs de 800 gal (4 800 kg) : + +| Menu | Lot | Armes | Total | +|---|---|---|---| +| en tête | Apache | 40 Hellfire (25 laser, 15 radar), 2 400 obus de 30 mm | 7 868 kg | +| en tête | Apache | 60 Hellfire (38 laser, 22 radar), 1 200 obus | 8 199 kg | +| en tête | Apache | 40 Hellfire laser, 2 400 obus | 7 812 kg | +| en tête | Apache | 60 Hellfire laser, 1 200 obus | 8 118 kg | +| Russes | Ka-50 | 40 Vikhr, 460 obus de 2A42 explosifs et 460 perforants | 7 382 kg | +| Russes | Ka-50 | 60 Vikhr, 230 obus de 2A42 explosifs et 230 perforants | 7 891 kg | +| Russes | Mi-24P | 32 Ataka, 500 obus de GSh-30 | 6 809 kg | +| Russes | Mi-24P | 32 Shturm, 500 obus de GSh-30 | 6 358 kg | +| Mixtes | Apache + Ka-50 | 20 Hellfire (12 laser, 8 radar), 20 Vikhr, 1 200 obus de 30 mm, 460 de 2A42 | 7 627 kg | +| Mixtes | Apache + Mi-24P | 20 Hellfire laser, 16 Ataka, 1 200 obus de 30 mm, 500 de GSh-30 | 7 523 kg | + +Tous restent sous les 8 837 kg qu'emporte un CH-47F avec 80 % de son carburant interne (22 680 kg au plus, 11 400 kg à vide, 2 444 kg de carburant, d'après la base d'unités de DCS) ; une cow IA reçoit ces 80 %. +Les masses des missiles sont celles du data mine DCS (AGM-114K 45,3 kg, AGM-114L 49 kg, Vikhr 45 kg, Ataka 49,5 kg, Shturm 35,4 kg) ; les obus comptent 0,5 kg (30 mm de l'Apache) et 0,85 kg (30 × 165 russe) emballage compris, une estimation. + +`item` est le nom que DCS donne à l'arme dans un entrepôt. +`weapons.missiles.AGM_114K` a été vérifié en jeu pour le Hellfire laser. +Les autres viennent du data mine DCS et ne sont pas encore vérifiés comme articles d'entrepôt : `weapons.missiles.AGM_114` (AGM-114L), `weapons.shells.M230_HEDP M789` (les obus de l'Apache), `weapons.missiles.Vikhr_M`, `weapons.missiles.Ataka_9M120`, `weapons.missiles.AT_6` (Shturm), `weapons.shells.2A42_30_HE` et `2A42_30_AP` (Ka-50), `weapons.shells.GSh_30_2K_HE` (Mi-24P). + +### Caisses cow + +Une cow pilotée emporte son lot dans des caisses : **Charger** le répartit dans les `fatCowCrates` qui portent chaque partie, toutes pleines sauf la dernière de chaque sorte. + +```yaml +fatCowCrates: +- desc: Fat Cow fuel tank (800 gal) + weight: 2400 # kg en soute quand elle est pleine ; aussi la clé de la caisse dans le catalogue + fuelKg: 2400 # carburant jet d'une caisse pleine +- desc: Fat Cow laser Hellfires (x20) + weight: 906 # 20 x 45,3 kg + item: weapons.missiles.AGM_114K + count: 20 +``` + +Une entrée a soit `fuelKg`, soit `item` et `count` ; une caisse en partie pleine pèse sa part. +Par défaut : un réservoir de 800 gal (2 400 kg) ; 20 Hellfire laser (906 kg), 20 Hellfire radar (980 kg), 1 200 obus de 30 mm (600 kg) ; 20 Vikhr (900 kg), 460 obus de 2A42 explosifs ou perforants (391 kg chacun) ; 16 Ataka (792 kg), 16 Shturm (566 kg), 500 obus de GSh-30 (425 kg). +Chaque lot par défaut tient en 5 à 8 caisses ; un lot qui dépasserait le `maxCratesOnboard` de l'appareil (8 pour le CH-47F) est refusé, tout comme un lot qui emporte un article qu'aucune caisse ne porte. +**Demander une caisse** amène une caisse pleine à la fois. +Les caisses cow n'apparaissent ni dans **Request Equipment** ni dans **Unpack Crate**. + +## Placer une cow IA dans une mission + +Un joueur peut demander une cow IA de n'importe où, au sol comme en vol : elle arrive en vol depuis la zone logistique amie à terre la plus proche du demandeur, et y retourne pour disparaître. +La patrouille la commande ensuite depuis le F10 (Atterris ici, Redécolle, Reste ici en orbite, Rejoins-nous, Rentre à la base) : la cow d'une patrouille de joueurs ne bouge jamais d'elle-même. +Pour donner sa cow à une patrouille dès le départ, placez la cow dans l'éditeur de mission et déclarez-la : + +1. Placez un groupe **CH-47F**, avec **activation différée** cochée, là où la cow doit démarrer (en général la base de la patrouille). + Son premier waypoint est un décollage depuis le sol ; il n'en faut pas d'autre. +2. Déclarez-le, avec le groupe qu'il escorte : + +```yaml +fatCowAICows: +- group: Fat Cow Gunfighter # le groupe CH-47, nom exact + escort: Gunfighter # la patrouille escortée, nom de groupe exact + loadout: 2 # facultatif : le lot de fatCowLoadouts qu'elle emporte, 1 par défaut +``` + +CTLD active la cow dès que le groupe escorté est en jeu : au démarrage de la mission pour une patrouille IA, ou quand le premier joueur prend un slot d'une patrouille de clients. +Si la patrouille escortée est une patrouille IA, la cow se pose à côté du leader quand il se pose, se déploie après `fatCowDeployDelay`, se replie et suit quand le leader décolle, et rentre à son point de départ une fois vide : une patrouille IA ne peut pas utiliser le menu. +Si c'est une patrouille de clients, ses joueurs commandent la cow depuis le F10, comme une cow demandée. + +Au démarrage, CTLD note dans le log ce que DCS renvoie pour l'entrepôt des FARP de la mission (`FARP warehouse '': jet fuel …`) : une mesure, avant que **Statut FARP** couvre toutes les FARP et pas seulement celles des Fat Cow. + +Cette déclaration est le contrat sur lequel s'appuient les VEAF Mission Creation Tools pour ajouter une cow qui escorte une patrouille nommée : un groupe d'hélicoptère en activation différée, et une entrée `fatCowAICows` `{ group, escort, loadout }` (`loadout` facultatif). +CTLD ne reconnaît jamais une cow à son nom. diff --git a/docs/mission-maker/fat-cow.md b/docs/mission-maker/fat-cow.md new file mode 100644 index 00000000..99409f3d --- /dev/null +++ b/docs/mission-maker/fat-cow.md @@ -0,0 +1,136 @@ +# Fat Cow + +A Fat Cow is a CH-47 that lands in the field and opens a FARP stocked with what it carries. +The pilot side is described in [Fat Cow (pilots)](../pilot/fat-cow.md); this page covers the settings and how to place a cow in a mission. + +## What CTLD lays out + +When a cow deploys, CTLD plays the **Fat Cow FARP** scene at the cow: + +| Object | Where | Role | +|---|---|---| +| Invisible FARP | at the cow | the airbase whose warehouse holds the cow's stock | +| FARP Fuel Depot | 25 m behind the ramp | the supplier the ground crews need | +| 2 ammo crates, 1 set of barrels | 20 m behind, to the right | dressing, never loadable | +| 3 soldiers | 25 m behind, to the left | standing by | + +No vehicle, no tent, no ammo dump. +This was measured in DCS 2.9.30.28738: a FARP spawned during a mission serves a player already in game, but only with a supplier in range, and the FARP Fuel Depot alone supplies both fuel and weapons (fuel trucks, ammo trucks and the ammo dump are not needed; cargo crates and barrels supply nothing). + +Each cow has one FARP of its own, named `CTLD_FatCow_FARP_`. +Packing removes the depot, the dressing and the crew and empties the FARP's warehouse, but leaves the FARP itself: a heliport destroyed during a mission stays on the F10 map, and an AI cow sitting on its FARP blew up when the FARP was destroyed under it. +The next deployment adds the FARP again under the same name, where the cow now is. +So an emptied FARP stays on the map where each cow last deployed; with no supplier next to it, it serves nothing. + +The FARP's warehouse is set to the cow's stock and nothing else: jet fuel, and each ammunition item the cow carries. +DCS serves it until it runs out. +When a player joins the server, CTLD writes every open cow's warehouse again, at once and two seconds later, with the quantities the server holds: whether DCS shares a scripted warehouse with clients was not measured. +CTLD also reads and writes them every 10 seconds. +A quantity that went up was refilled by another script (the VEAF Mission Creation Tools refill every FARP when a player joins): CTLD keeps the cow's own count and puts it back, and removes any weapon the cow does not carry. +What was taken in the few seconds before such a refill can come back, and ammunition a helicopter hands back while rearming is not added to the cow's stock. +A cow FARP that is not open is emptied at the same moments. +The FARP of an AI cow gone home is reused by the next one, so the map does not gain an empty FARP with each cow requested. + +## Settings + +All in the `fob` family of the configuration. + +| Setting | Default | Meaning | +|---|---|---| +| `fatCowEnabled` | `true` | The F10 **Fat Cow** menu and the cow crates. | +| `fatCowTypes` | `[CH-47Fbl1]` | Types a player can fly as a cow. Each must also be a transport in `capabilitiesByType`. | +| `fatCowCrates` | fuel tank; Hellfires and 30 mm (Apache); Vikhr and 2A42 rounds (Ka-50); Ataka, Shturm and GSh-30 rounds (Mi-24P) | The crates a player cow carries its stock in (see below). | +| `fatCowLoadouts` | four Apache, four Russian and two mixed loadouts, each with 4 800 kg of fuel | What a cow carries: loaded by a player cow, picked by a flight requesting an AI cow (see below). | +| `fatCowDeployDelay` | `60` | Seconds an AI cow stays on the ground before its FARP opens. | +| `fatCowAIType` | `CH-47Fbl1` | Type of the helicopter brought in when a flight requests a cow. Empty removes **Request a Fat Cow**. | +| `fatCowAICows` | `[]` | Cows placed in the mission (see below). | + +### Loadouts + +Each `fatCowLoadouts` entry is what a cow carries: jet fuel in kg, and DCS warehouse items with their counts. + +```yaml +fatCowLoadouts: +- desc: 40 Hellfires (25 laser, 15 radar), 2400 rounds + fuelKg: 4800 # two 800 gal tanks + items: + - item: weapons.missiles.AGM_114K # laser Hellfire + count: 25 + - item: weapons.missiles.AGM_114 # radar Hellfire (AGM-114L) + count: 15 + - item: weapons.shells.M230_HEDP M789 # 30 mm + count: 2400 +``` + +A player cow finds them under **Load**, in a logistic zone; a flight requesting an AI cow picks one under **Request a Fat Cow**. +A loadout with `menu: russian` or `menu: mixed` shows in the **Russian** or **Mixed** submenu, one without at the top. +The cow is always a CH-47 (`fatCowAIType`), whatever the loadout and whatever the requester flies: it is the module players fly as a cow. + +The defaults all carry two 800 gal tanks (4 800 kg): + +| Menu | Loadout | Weapons | Total | +|---|---|---|---| +| top | Apache | 40 Hellfires (25 laser, 15 radar), 2 400 rounds of 30 mm | 7 868 kg | +| top | Apache | 60 Hellfires (38 laser, 22 radar), 1 200 rounds | 8 199 kg | +| top | Apache | 40 laser Hellfires, 2 400 rounds | 7 812 kg | +| top | Apache | 60 laser Hellfires, 1 200 rounds | 8 118 kg | +| Russian | Ka-50 | 40 Vikhr, 460 rounds of 2A42 HE and 460 AP | 7 382 kg | +| Russian | Ka-50 | 60 Vikhr, 230 rounds of 2A42 HE and 230 AP | 7 891 kg | +| Russian | Mi-24P | 32 Ataka, 500 rounds of GSh-30 | 6 809 kg | +| Russian | Mi-24P | 32 Shturm, 500 rounds of GSh-30 | 6 358 kg | +| Mixed | Apache + Ka-50 | 20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1 200 rounds of 30 mm, 460 of 2A42 | 7 627 kg | +| Mixed | Apache + Mi-24P | 20 laser Hellfires, 16 Ataka, 1 200 rounds of 30 mm, 500 of GSh-30 | 7 523 kg | + +All are under the 8 837 kg a CH-47F lifts with 80 % of its internal fuel (22 680 kg at most, 11 400 kg empty, 2 444 kg of fuel, from DCS's units database); an AI cow is given those 80 %. +Missile masses are the DCS data mine's (AGM-114K 45.3 kg, AGM-114L 49 kg, Vikhr 45 kg, Ataka 49.5 kg, Shturm 35.4 kg); rounds are counted 0.5 kg (30 mm Apache) and 0.85 kg (30 × 165 Russian) with their packing, an estimate. + +`item` is the name DCS gives the weapon in a warehouse. +`weapons.missiles.AGM_114K` was checked in game for the laser Hellfire. +The others come from the DCS data mine and are not yet checked as warehouse items: `weapons.missiles.AGM_114` (AGM-114L), `weapons.shells.M230_HEDP M789` (the Apache's rounds), `weapons.missiles.Vikhr_M`, `weapons.missiles.Ataka_9M120`, `weapons.missiles.AT_6` (Shturm), `weapons.shells.2A42_30_HE` and `2A42_30_AP` (Ka-50), `weapons.shells.GSh_30_2K_HE` (Mi-24P). + +### Cow crates + +A player cow carries its loadout in crates: **Load** splits it into the `fatCowCrates` that carry each part, every crate full but the last of each kind. + +```yaml +fatCowCrates: +- desc: Fat Cow fuel tank (800 gal) + weight: 2400 # kg in the hold when full; also the crate's catalogue key + fuelKg: 2400 # jet fuel a full crate holds +- desc: Fat Cow laser Hellfires (x20) + weight: 906 # 20 x 45.3 kg + item: weapons.missiles.AGM_114K + count: 20 +``` + +An entry has either `fuelKg`, or `item` and `count`; a part-full crate weighs its share. +The defaults are an 800 gal tank (2 400 kg); 20 laser Hellfires (906 kg), 20 radar Hellfires (980 kg), 1 200 rounds of 30 mm (600 kg); 20 Vikhr (900 kg), 460 rounds of 2A42 HE or AP (391 kg each); 16 Ataka (792 kg), 16 Shturm (566 kg), 500 rounds of GSh-30 (425 kg). +Every default loadout fits in 5 to 8 crates; a loadout that would exceed the aircraft's `maxCratesOnboard` (8 for the CH-47F) is refused, and so is one carrying an item no crate carries. +**Request a crate** brings one full crate at a time. +Cow crates never show in **Request Equipment** nor in **Unpack Crate**. + +## Placing an AI cow in a mission + +A player can request an AI cow from anywhere, on the ground or in the air: it comes airborne from the friendly logistic zone on land nearest to the requester, and goes back there to despawn. +The flight then commands it from F10 (Land here, Take off, Hold here, Join us, RTB): a player flight's cow never moves on its own. +To give a flight its cow from the start instead, place the cow in the Mission Editor and declare it: + +1. Place a **CH-47F** group, **late activation** ticked, where the cow should start (usually the flight's base). + Its first waypoint is a take-off from the ground; it needs no other waypoint. +2. Declare it, with the group it escorts: + +```yaml +fatCowAICows: +- group: Fat Cow Gunfighter # the CH-47 group, exact name + escort: Gunfighter # the flight it escorts, exact group name + loadout: 2 # optional: the fatCowLoadouts entry it carries, 1 by default +``` + +CTLD activates the cow as soon as the escorted group is in the world: at mission start for an AI flight, or when the first player takes a slot of a client flight. +If the escorted flight is an AI flight, the cow lands next to the leader when it lands, deploys after `fatCowDeployDelay`, packs and follows when the leader takes off, and flies back to where it started once empty: an AI flight cannot use the menu. +If it is a client flight, its players command the cow from F10, as a requested one. + +At start, CTLD logs what DCS reports for the warehouse of the mission's FARPs (`FARP warehouse '': jet fuel …`): a measurement before **FARP status** covers every FARP, and not only Fat Cow ones. + +This declaration is the contract the VEAF Mission Creation Tools rely on to add a cow escorting a named flight: a late-activated helicopter group, and one `fatCowAICows` entry `{ group, escort, loadout }` (`loadout` optional). +CTLD never recognises a cow by its name. diff --git a/docs/pilot/fat-cow.fr.md b/docs/pilot/fat-cow.fr.md new file mode 100644 index 00000000..bfd38f7c --- /dev/null +++ b/docs/pilot/fat-cow.fr.md @@ -0,0 +1,73 @@ +# Fat Cow + +Une Fat Cow est un CH-47 qui se pose sur le terrain et devient un point de ravitaillement et de réarmement avancé pour les autres hélicoptères. +L'US Army la décrit comme une FARP déployée en un instant : le Chinook emporte son carburant dans des réservoirs en soute et le distribue au sol. + +DCS ne sait pas faire passer du carburant d'un appareil à l'autre, alors CTLD installe une petite FARP à côté de la cow : un héliport invisible, un dépôt de carburant derrière la rampe, quelques caisses et trois soldats. +Le stock de la FARP est exactement ce que la cow transporte. +Une fois épuisé, il n'y en a plus. + +Il y a deux façons d'avoir une cow : la piloter soi-même, ou en demander une IA à CTLD. +Tout se trouve dans **F10 → CTLD → Fat Cow**. + +## Piloter une cow (CH-47F) + +1. Posez-vous dans une zone logistique. +2. Chargez ce que vous voulez emporter : + - **Charger**, puis un lot : les caisses vont directement en soute. + Chaque lot emporte deux réservoirs de 800 gal (4 800 kg de carburant jet) et des armes : + + | Menu | Lots | + |---|---| + | **Charger** (Apache) | 40 Hellfire (25 laser, 15 radar) et 2 400 obus de 30 mm ; 60 Hellfire (38 laser, 22 radar) et 1 200 obus ; 40 Hellfire laser et 2 400 obus ; 60 Hellfire laser et 1 200 obus | + | **Charger → Russes** | Ka-50 : 40 Vikhr et 920 obus de 2A42 (moitié explosifs, moitié perforants), ou 60 Vikhr et 460 obus ; Mi-24P : 32 Ataka ou 32 Shturm, avec 500 obus de GSh-30 | + | **Charger → Mixtes** | 20 Hellfire (12 laser, 8 radar), 20 Vikhr, 1 200 obus de 30 mm et 460 de 2A42 ; 20 Hellfire laser, 16 Ataka, 1 200 obus de 30 mm et 500 de GSh-30 | + + Cela fait de 6 400 à 8 200 kg en 5 à 8 caisses, ce qu'emporte un CH-47F avec 80 % de son carburant interne : inutile de faire le plein. + - ou une caisse à la fois, **Demander une caisse** : un réservoir (2 400 kg), 20 Hellfire laser ou 20 radar, 1 200 obus de 30 mm, 20 Vikhr, 16 Ataka, 16 Shturm, 460 obus de 2A42 explosifs ou perforants, 500 obus de GSh-30, puis chargez-la comme n'importe quelle caisse CTLD. + + **Décharger les caisses Fat Cow** vide la soute, pour composer un autre lot. +3. Volez jusqu'aux hélicoptères d'attaque qui ont besoin de vous, et posez-vous. +4. **Déployer la FARP Fat Cow.** + La FARP ouvre quelques secondes plus tard et on vous annonce ce qu'elle contient. + Les caisses restent dans votre soute : le carburant ne quitte jamais le Chinook. +5. Les autres hélicoptères se posent à côté de vous et se ravitaillent par le menu de leur équipe au sol, comme sur n'importe quelle FARP. +6. **Replier la FARP Fat Cow**, ou décollez simplement : la FARP est repliée pour vous. + Ce qui n'a pas servi retourne dans vos caisses, la soute s'allège, et une caisse vidée disparaît. + +**Stock de la Fat Cow** vous dit ce qu'il vous reste, au sol comme en vol. +Si vous quittez votre slot pendant que la FARP est ouverte, la FARP reste, avec ce qu'elle contient encore, jusqu'à ce qu'un pilote reprenne ce slot : elle se ferme alors, vidée, et le nouveau pilote repart soute vide. + +!!! note "Quand le stock s'épuise" + Un hélicoptère qui demande plus que ce qui reste reçoit ce qui reste, et son équipe au sol répond « Unable to comply ». + Une demande sur une FARP vide reçoit « We don't have this in stock ». + +## Demander une cow IA + +Tout joueur dont l'appareil n'est pas une cow peut **Demander une Fat Cow**, au sol comme en vol, et choisir son lot. +Un CH-47 décolle de la zone logistique amie la plus proche de vous, et on vous annonce d'où il vient, à quelle distance il est et quand il devrait vous rejoindre. + +La cow fait ensuite ce que votre patrouille lui dit, et rien d'elle-même : + +| Commande | Ce que fait la cow | +|---|---| +| **Atterris ici** | se pose à environ 120 m à votre gauche et ouvre sa FARP une minute après avoir touché le sol | +| **Redécolle** | replie sa FARP, décolle et tourne sur place | +| **Reste ici en orbite** | replie sa FARP si besoin et tourne au-dessus de votre position | +| **Rejoins-nous** | replie sa FARP si besoin et suit votre patrouille | +| **Rentre à la base** | replie sa FARP et rentre à la base, où elle disparaît | + +Vous pouvez donc redécoller pour vous déplacer en stationnaire jusqu'à sa FARP, ou vous repositionner, sans que la cow parte. + +**Statut Fat Cow** dit où est la cow de votre patrouille (phase, distance et relèvement), quand elle devrait vous rejoindre, et ce qu'elle contient encore, y compris avant qu'elle se soit posée. + +**Demander une Fat Cow** ouvre la liste des lots, les mêmes que ceux d'une cow pilotée (Apache en tête, **Russes** et **Mixtes** un niveau plus bas) : choisissez ce dont votre patrouille a besoin. +C'est toujours un CH-47, quel que soit votre appareil. +Une cow par patrouille. + +!!! tip "Posez-vous à découvert" + Choisissez un terrain dégagé pour **Atterris ici** : un hélicoptère envoyé se poser en lisière de forêt y reste en stationnaire sans jamais toucher le sol. + +## Ce que contient encore une FARP + +**Statut FARP**, dans le même menu, dit ce que contient encore la FARP Fat Cow où vous êtes (la plus proche à moins de 2 km), à qui qu'elle appartienne. diff --git a/docs/pilot/fat-cow.md b/docs/pilot/fat-cow.md new file mode 100644 index 00000000..9d1106e6 --- /dev/null +++ b/docs/pilot/fat-cow.md @@ -0,0 +1,73 @@ +# Fat Cow + +A Fat Cow is a CH-47 that lands in the field and becomes a forward refuel and rearm point for other helicopters. +The US Army calls it "a rapidly employed FARP": the Chinook carries its fuel in tanks inside the hold and hands it out on the ground. + +DCS cannot pass fuel from one aircraft to another, so CTLD lays out a small FARP next to the cow: an invisible helipad, a fuel depot behind the ramp, a few crates and three soldiers standing by. +The FARP's stock is exactly what the cow carries. +When it is gone, it is gone. + +There are two ways to get a cow: fly one yourself, or ask CTLD for an AI one. +Everything is in **F10 → CTLD → Fat Cow**. + +## Flying a cow (CH-47F) + +1. Land in a logistic zone. +2. Load what you want to carry: + - **Load**, then a loadout: the crates go straight into the hold. + Each loadout carries two 800 gal tanks (4 800 kg of jet fuel) and weapons: + + | Menu | Loadouts | + |---|---| + | **Load** (Apache) | 40 Hellfires (25 laser, 15 radar) and 2 400 rounds of 30 mm; 60 Hellfires (38 laser, 22 radar) and 1 200 rounds; 40 laser Hellfires and 2 400 rounds; 60 laser Hellfires and 1 200 rounds | + | **Load → Russian** | Ka-50: 40 Vikhr and 920 rounds of 2A42 (half HE, half AP), or 60 Vikhr and 460 rounds; Mi-24P: 32 Ataka or 32 Shturm, with 500 rounds of GSh-30 | + | **Load → Mixed** | 20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1 200 rounds of 30 mm and 460 of 2A42; 20 laser Hellfires, 16 Ataka, 1 200 rounds of 30 mm and 500 of GSh-30 | + + That is 6 400 to 8 200 kg in 5 to 8 crates, what a CH-47F lifts with 80 % of its internal fuel: no need to fill the tanks. + - or one crate at a time, **Request a crate**: a fuel tank (2 400 kg), 20 laser or 20 radar Hellfires, 1 200 rounds of 30 mm, 20 Vikhr, 16 Ataka, 16 Shturm, 460 rounds of 2A42 HE or AP, 500 rounds of GSh-30, then load it like any CTLD crate. + + **Unload the Fat Cow crates** empties the hold, to make up another loadout. +3. Fly to where the attack helicopters need you, and land. +4. **Deploy the Fat Cow FARP.** + The FARP opens a few seconds later and you are told what it holds. + The crates stay in your hold: the fuel never leaves the Chinook. +5. The other helicopters land next to you and rearm and refuel through their ground crew menu, as on any FARP. +6. **Pack the Fat Cow FARP**, or simply take off: the FARP is packed for you. + What was not used goes back into your crates, the hold gets lighter, and a crate left empty disappears. + +**Fat Cow stock** tells you what you still have, on the ground or in the air. +If you leave your slot while the FARP is open, the FARP stays, with what it still holds, until a pilot takes that slot again: it then closes, emptied, and the new pilot starts with an empty hold. + +!!! note "When the stock runs out" + A helicopter asking for more than what is left gets what is left, and its ground crew answers "Unable to comply". + A request on an empty FARP gets "We don't have this in stock". + +## Asking for an AI cow + +Any player whose aircraft is not a cow can **Request a Fat Cow**, on the ground or in the air, and pick its loadout. +A CH-47 takes off from the friendly logistic zone nearest to you, and you are told where it comes from, how far it is and when it should join. + +The cow then does what your flight tells it, and nothing on its own: + +| Command | What the cow does | +|---|---| +| **Land here** | lands about 120 m to your left and opens its FARP one minute after touching down | +| **Take off** | packs its FARP, lifts off and orbits where it stands | +| **Hold here** | packs its FARP if needed and orbits over your position | +| **Join us** | packs its FARP if needed and follows your flight | +| **RTB** | packs its FARP and flies back to base, where it disappears | + +So you can lift off to hover-taxi onto its FARP, or reposition, without the cow leaving. + +**Fat Cow status** tells where your flight's cow is (phase, distance and bearing), when it should join, and what it still holds, before it has landed too. + +**Request a Fat Cow** opens the list of loadouts, the same as a piloted cow's (Apache at the top, **Russian** and **Mixed** one level down): pick what your flight needs. +It is always a CH-47, whatever you fly. +One cow per flight. + +!!! tip "Land in the open" + Pick open ground for **Land here**: a helicopter sent to land at the edge of a forest hovers there and never touches down. + +## What a FARP still holds + +**FARP status**, in the same menu, tells what the Fat Cow FARP you are at still holds (the nearest one within 2 km), whoever it belongs to. diff --git a/missions/Test_FatCow_Spike.miz b/missions/Test_FatCow_Spike.miz new file mode 100644 index 00000000..d252b62e Binary files /dev/null and b/missions/Test_FatCow_Spike.miz differ diff --git a/mkdocs.yml b/mkdocs.yml index b9731470..a9725769 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,6 +121,7 @@ nav: - Beacons: pilot/beacons.md - Smoke: pilot/smoke.md - Pack: pilot/pack.md + - Fat Cow: pilot/fat-cow.md - Mission Maker: - Overview: mission-maker/index.md - Configuration: mission-maker/configuration.md @@ -129,6 +130,7 @@ nav: - Scenes & FOB: mission-maker/scenes-fob.md - Crate catalogue: mission-maker/crates-catalogue.md - Minefield: mission-maker/minefield.md + - Fat Cow: mission-maker/fat-cow.md - Validating your config: mission-maker/asset-validation.md - Translations: mission-maker/translations.md - Legacy API: mission-maker/legacy-api.md diff --git a/src/CTLD_config.yaml b/src/CTLD_config.yaml index e5947482..6e338730 100644 --- a/src/CTLD_config.yaml +++ b/src/CTLD_config.yaml @@ -2,7 +2,7 @@ # Sections mm_facing / advanced are for readability; the generator merges them. # configVersion: the schema/catalogue version this document is authored against. A # configUser carries it too, so the tool can detect a version gap on a CTLD upgrade. -configVersion: "2.2.0" +configVersion: "2.3.0" mm_facing: AASystemCrateStacking: false AASystemLimitBLUE: 20 @@ -276,6 +276,149 @@ mm_facing: - extract25 farpTroopPickupRadius: 150 fastRopeMaximumHeight: 18.28 + fatCowAICows: [] + fatCowAIType: CH-47Fbl1 + fatCowCrates: + - desc: Fat Cow fuel tank (800 gal) + weight: 2400 + fuelKg: 2400 + - desc: Fat Cow laser Hellfires (x20) + weight: 906 + item: weapons.missiles.AGM_114K + count: 20 + - desc: Fat Cow radar Hellfires (x20) + weight: 980 + item: weapons.missiles.AGM_114 + count: 20 + - desc: Fat Cow 30 mm (x1200) + weight: 600 + item: weapons.shells.M230_HEDP M789 + count: 1200 + - desc: Fat Cow Vikhr (x20) + weight: 900 + item: weapons.missiles.Vikhr_M + count: 20 + - desc: Fat Cow Ataka (x16) + weight: 792 + item: weapons.missiles.Ataka_9M120 + count: 16 + - desc: Fat Cow Shturm (x16) + weight: 566 + item: weapons.missiles.AT_6 + count: 16 + - desc: Fat Cow 2A42 HE (x460) + weight: 391 + item: weapons.shells.2A42_30_HE + count: 460 + - desc: Fat Cow 2A42 AP (x460) + weight: 391 + item: weapons.shells.2A42_30_AP + count: 460 + - desc: Fat Cow GSh-30 (x500) + weight: 425 + item: weapons.shells.GSh_30_2K_HE + count: 500 + fatCowDeployDelay: 60 + fatCowEnabled: true + fatCowLoadouts: + - desc: 40 Hellfires (25 laser, 15 radar), 2400 rounds + fuelKg: 4800 + items: + - item: weapons.missiles.AGM_114K + count: 25 + - item: weapons.missiles.AGM_114 + count: 15 + - item: weapons.shells.M230_HEDP M789 + count: 2400 + - desc: 60 Hellfires (38 laser, 22 radar), 1200 rounds + fuelKg: 4800 + items: + - item: weapons.missiles.AGM_114K + count: 38 + - item: weapons.missiles.AGM_114 + count: 22 + - item: weapons.shells.M230_HEDP M789 + count: 1200 + - desc: 40 laser Hellfires, 2400 rounds + fuelKg: 4800 + items: + - item: weapons.missiles.AGM_114K + count: 40 + - item: weapons.shells.M230_HEDP M789 + count: 2400 + - desc: 60 laser Hellfires, 1200 rounds + fuelKg: 4800 + items: + - item: weapons.missiles.AGM_114K + count: 60 + - item: weapons.shells.M230_HEDP M789 + count: 1200 + - desc: 40 Vikhr, 920 rounds (Ka-50) + menu: russian + fuelKg: 4800 + items: + - item: weapons.missiles.Vikhr_M + count: 40 + - item: weapons.shells.2A42_30_HE + count: 460 + - item: weapons.shells.2A42_30_AP + count: 460 + - desc: 60 Vikhr, 460 rounds (Ka-50) + menu: russian + fuelKg: 4800 + items: + - item: weapons.missiles.Vikhr_M + count: 60 + - item: weapons.shells.2A42_30_HE + count: 230 + - item: weapons.shells.2A42_30_AP + count: 230 + - desc: 32 Ataka, 500 rounds (Mi-24P) + menu: russian + fuelKg: 4800 + items: + - item: weapons.missiles.Ataka_9M120 + count: 32 + - item: weapons.shells.GSh_30_2K_HE + count: 500 + - desc: 32 Shturm, 500 rounds (Mi-24P) + menu: russian + fuelKg: 4800 + items: + - item: weapons.missiles.AT_6 + count: 32 + - item: weapons.shells.GSh_30_2K_HE + count: 500 + - desc: 20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds + menu: mixed + fuelKg: 4800 + items: + - item: weapons.missiles.AGM_114K + count: 12 + - item: weapons.missiles.AGM_114 + count: 8 + - item: weapons.missiles.Vikhr_M + count: 20 + - item: weapons.shells.M230_HEDP M789 + count: 1200 + - item: weapons.shells.2A42_30_HE + count: 230 + - item: weapons.shells.2A42_30_AP + count: 230 + - desc: 20 laser Hellfires, 16 Ataka, 1200 + 500 rounds + menu: mixed + fuelKg: 4800 + items: + - item: weapons.missiles.AGM_114K + count: 20 + - item: weapons.missiles.Ataka_9M120 + count: 16 + - item: weapons.shells.M230_HEDP M789 + count: 1200 + - item: weapons.shells.GSh_30_2K_HE + count: 500 + fatCowTypes: + - CH-47Fbl1 fobCrateCollectionRadius: 750 fobDestructionThreshold: 0.5 fobLogisticZoneRadius: 150 diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index 842d7380..77e5d651 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -454,6 +454,50 @@ farpTroopPickupRadius: description: en: Radius (m) within which troops may board at a FARP fr: Rayon (m) dans lequel les troops peuvent embarquer à une FARP +fatCowEnabled: + group: fob + standard: true + label: {en: Fat Cow, fr: Fat Cow (FARP héliportée)} + description: + en: A CH-47 lands in the field and opens a FARP stocked with what it carries — flown by a player with Fat Cow crates in its hold, or an AI cow requested by a flight. Off removes the F10 Fat Cow menu and the cow crates. + fr: Un CH-47 se pose sur le terrain et ouvre une FARP approvisionnée avec ce qu'il transporte — piloté par un joueur avec des caisses Fat Cow en soute, ou une cow IA demandée par une patrouille. Désactivé, le menu F10 Fat Cow et les caisses cow disparaissent. +fatCowTypes: + group: fob + label: {en: Fat Cow aircraft types, fr: Types d'appareils Fat Cow} + description: + en: DCS types a player can fly as a Fat Cow (request cow crates, deploy and pack the FARP). Each must also be a CTLD transport in capabilitiesByType. + fr: Types DCS qu'un joueur peut piloter en Fat Cow (demander les caisses cow, déployer et replier la FARP). Chacun doit aussi être un transport CTLD dans capabilitiesByType. +fatCowCrates: + group: fob + label: {en: Fat Cow crates, fr: Caisses Fat Cow} + description: + en: 'The crates a player cow carries its stock in: each has a desc, a weight (kg in the hold when full, and its catalogue key) and either fuelKg (jet fuel) or an item (a DCS warehouse item name) with the count a full crate holds. A loadout is split into these crates. Default: an 800 gal ERFS tank of 2400 kg; 20 laser and 20 radar Hellfires, 1200 rounds of 30 mm (Apache); 20 Vikhr, 460 rounds of 2A42 HE and of AP (Ka-50); 16 Ataka, 16 Shturm, 500 rounds of GSh-30 (Mi-24P).' + fr: "Les caisses dans lesquelles une cow pilotée emporte son stock : chacune a un desc, un poids (kg en soute quand elle est pleine, et sa clé dans le catalogue) et soit fuelKg (carburant jet), soit un item (nom d'article d'entrepôt DCS) avec le nombre qu'en contient une caisse pleine. Un lot de chargement est réparti dans ces caisses. Par défaut : un réservoir ERFS de 800 gal de 2400 kg ; 20 Hellfire laser et 20 radar, 1200 obus de 30 mm (Apache) ; 20 Vikhr, 460 obus de 2A42 explosifs et autant de perforants (Ka-50) ; 16 Ataka, 16 Shturm, 500 obus de GSh-30 (Mi-24P)." +fatCowLoadouts: + group: fob + label: {en: Fat Cow loadouts, fr: Lots de chargement Fat Cow} + description: + en: 'What a cow carries: a player cow loads one into its hold in a logistic zone, a flight requesting an AI cow picks one. Each loadout has a desc, fuelKg (jet fuel), a list of items (DCS warehouse item name and count) and optionally menu (russian or mixed: the submenu it shows in; none shows it at the top). Default: two 800 gal tanks (4800 kg) and, at the top, 40 or 60 Hellfires with 30 mm rounds for the Apache; under Russian, Vikhr and 2A42 rounds for the Ka-50, Ataka or Shturm and GSh-30 rounds for the Mi-24P; under Mixed, two Apache and Russian mixes. 6400 to 8200 kg, under what a CH-47F with 80 % of its internal fuel lifts.' + fr: "Ce qu'emporte une cow : une cow pilotée en charge un en soute dans une zone logistique, une patrouille qui demande une cow IA en choisit un. Chaque lot a un desc, fuelKg (carburant jet), une liste d'items (nom d'article d'entrepôt DCS et nombre) et au besoin menu (russian ou mixed : le sous-menu où il apparaît ; sans menu, il est en tête). Par défaut : deux réservoirs de 800 gal (4800 kg) et, en tête, 40 ou 60 Hellfire avec des obus de 30 mm pour l'Apache ; sous Russes, des Vikhr et des obus de 2A42 pour le Ka-50, des Ataka ou des Shturm et des obus de GSh-30 pour le Mi-24P ; sous Mixtes, deux mélanges Apache et russes. De 6400 à 8200 kg, sous ce qu'emporte un CH-47F avec 80 % de son carburant interne." +fatCowDeployDelay: + group: fob + label: {en: AI Fat Cow deployment delay, fr: Délai de déploiement de la cow IA} + unit: s + description: + en: Seconds an AI cow stays on the ground before its FARP opens. + fr: Secondes qu'une cow IA passe au sol avant que sa FARP ouvre. +fatCowAIType: + group: fob + label: {en: AI Fat Cow type, fr: Type de la cow IA} + description: + en: DCS type of the helicopter brought in when a flight requests a Fat Cow. Empty removes the Request a Fat Cow command. + fr: Type DCS de l'hélicoptère amené quand une patrouille demande une Fat Cow. Vide, la commande Demander une Fat Cow disparaît. +fatCowAICows: + group: fob + label: {en: Mission-placed Fat Cows, fr: Fat Cows placées dans la mission} + description: + en: 'AI cows placed in the mission: each entry names a late-activated helicopter group (group), the flight it escorts (escort) and optionally the number of the fatCowLoadouts entry it carries (loadout, default 1). CTLD activates the cow once the flight is in the world.' + fr: "Cows IA placées dans la mission : chaque entrée nomme un groupe d'hélicoptère en activation différée (group), la patrouille qu'il escorte (escort) et, au besoin, le numéro du lot de fatCowLoadouts qu'il emporte (loadout, 1 par défaut). CTLD active la cow dès que la patrouille est en jeu." fobDestructionThreshold: group: fob label: {en: FOB destruction threshold, fr: Seuil de destruction d'une FOB} diff --git a/src/CTLD_core.lua b/src/CTLD_core.lua index 8c485fa8..3d884930 100644 --- a/src/CTLD_core.lua +++ b/src/CTLD_core.lua @@ -310,6 +310,11 @@ function CTLDCoreManager:init() -- INIT-A: AI transport auto-pickup/dropoff loop self:_initAITransports() + -- Fat Cow: cow crate descriptors and the cow's DCS events (FEAT-FAT-COW) + if ctld.gs("fatCowEnabled") == true then + CTLDFatCowManager.getInstance():start() + end + ctld.utils.log("INFO", "CTLDCoreManager: init complete (INIT-A + INIT-B + INIT-C + INIT-D + INIT-E)") end diff --git a/src/CTLD_crate.lua b/src/CTLD_crate.lua index 84a589e3..5f6f1d55 100644 --- a/src/CTLD_crate.lua +++ b/src/CTLD_crate.lua @@ -68,7 +68,8 @@ function CTLDCrate:init(data) self.loadTime = nil self.dcsStatic = data.dcsStatic or nil self.modelKey = data.modelKey or "load" - self.canBeUnpacked = true + -- A Fat Cow crate (fuel, ammo) is deployed from the hold by its cow, never unpacked on the ground. + self.canBeUnpacked = not (data.descriptor and data.descriptor.fatCow) -- Feature A: virtual parachute self.fromParachute = false -- true → eligible for autoUnpack on landing self.loadedByDCSNative = false -- true → native carry (DCS cargo UI): CTLD never unloads, parachutes or weighs it @@ -425,6 +426,24 @@ function CTLDCrateManager:_injectSceneCrate(sceneName, model) end end +--- Indexes a crate descriptor that lives outside the crate catalogue and its menus (Fat Cow +-- crates), on the first free weight from its own: weight is every crate's key. +-- @param descriptor table { weight, desc, unit, ... } -- weight may be moved to a free slot +-- @return the descriptor, indexed +function CTLDCrateManager:registerDescriptor(descriptor) + local w = descriptor.weight + while self._weightIndex[w] do + w = math.floor(w * 100 + 1.5) / 100 + end + if w ~= descriptor.weight then + ctld.utils.log("WARN", "registerDescriptor: '%s' weight %.2f taken, reassigned to %.2f", + tostring(descriptor.desc), descriptor.weight, w) + descriptor.weight = w + end + self._weightIndex[w] = descriptor + return descriptor +end + --- Pre-process spawnableCrates config into an internal structure used by the menu builder. -- Two-pass: -- Pass 1 — separate singleCrates (weight field) from mixedSets (mixedSet field). @@ -1101,7 +1120,8 @@ function CTLDCrateManager:getLoadedCrateWeight(unitName) local total = 0 for _, crate in pairs(self.crates) do if crate:isLoadedByCTLD() and self:isCarriedBy(crate, unitName) then - total = total + (crate.descriptor and crate.descriptor.weight or 0) + -- metadata.massKg: a part-used Fat Cow crate weighs what it still holds. + total = total + (crate.metadata.massKg or (crate.descriptor and crate.descriptor.weight) or 0) end end return total @@ -1973,8 +1993,9 @@ end -- @return bool function CTLDCrateManager:_respawnStatic(crate, position, heading) local label = crate.descriptor and crate.descriptor.desc or nil + -- metadata.massKg: a part-used Fat Cow crate comes back to the ground at what it still weighs. local newName, dcsStatic = self:_spawnStatic( - crate.descriptor.weight, position, crate.coalition, nil, crate.modelKey, label, heading) + crate.metadata.massKg or crate.descriptor.weight, position, crate.coalition, nil, crate.modelKey, label, heading) if not dcsStatic then return false end self.crates[crate.crateName] = nil @@ -2220,6 +2241,34 @@ function CTLDCrateManager:getCratesInRange(position, radius) return result end +--- Put a crate straight into a transport's hold, with no static on the ground (a Fat Cow loadout). +-- @param descriptor table crate descriptor +-- @param transport Unit +-- @param spawnedBy string unit name of the requester +-- @return CTLDCrate +function CTLDCrateManager:loadIntoHold(descriptor, transport, spawnedBy) + local crate = CTLDCrate:new({ + crateName = string.format("CTLD_Hold_%d", ctld.utils.getNextUniqId()), + descriptor = descriptor, + spawnMethod = CTLDCrate.SPAWN_METHOD.MENU_CTLD, + position = transport:getPoint(), + coalition = transport:getCoalition(), + spawnedBy = spawnedBy, + }) + self:_register(crate) + crate:load(transport) + ctld.utils.updateTransportWeight(transport:getName()) + self:_publish("OnCrateLoaded", { + crate = crate, + crateName = crate.crateName, + carrierUnitName = transport:getName(), + coalition = crate.coalition, + descriptor = descriptor, + timestamp = timer.getAbsTime(), + }) + return crate +end + --- Load a crate into a transport unit. -- Transitions crate: spawned|landed → loaded. -- Publishes OnCrateLoaded. diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua new file mode 100644 index 00000000..5c306f6f --- /dev/null +++ b/src/CTLD_fatCow.lua @@ -0,0 +1,1349 @@ +---@diagnostic disable +-- CTLD_fatCow.lua +-- Fat Cow — a CH-47 that lands in the field and becomes a forward refuel/rearm point (FEAT-FAT-COW). +-- +-- DCS has no fuel-transfer API: the cow is a FARP scene laid out next to it (CTLD_fatCowScene.lua), +-- whose warehouse holds what the cow carries. Measured in DCS 2.9.30.28738 (ticket 01): a runtime +-- Invisible FARP has a warehouse, starts empty, and serves its stock until it runs out. +-- +-- CTLDFatCow — one cow: its stock (fuel kg + weapon items by warehouse name) and its state. +-- CTLDFatCowManager — singleton, cows by unit name; deploy, pack, re-sync on player birth. +-- +-- Dependencies: class, CTLDSceneManager, CTLDCrateManager, CTLDZoneManager, CTLDPlayerManager, +-- CTLDDCSEventBridge, CTLDUtils (the scene: CTLDObjectRegistry) +-- ==================================================================================================== + +-- Warehouse liquid types: 0 jet fuel, 1 aviation gasoline, 2 MW50, 3 diesel. A cow carries jet fuel. +local JET_FUEL = 0 +local LIQUID_LAST = 3 + +-- ==================================================================================================== +-- CTLDFatCow +-- ==================================================================================================== + +CTLDFatCow = class() + +CTLDFatCow.STATE_STOWED = "stowed" -- the FARP is packed, the stock is on board +CTLDFatCow.STATE_DEPLOYING = "deploying" -- the scene is being laid out +CTLDFatCow.STATE_DEPLOYED = "deployed" -- the FARP is open: its warehouse holds the stock + +-- @param unitName string the cow's DCS unit name +-- @param coalitionId number +-- @param stock table { fuelKg = number, items = { [warehouse item name] = count } } (copied) +function CTLDFatCow:init(unitName, coalitionId, stock) + self.unitName = unitName + self.coalitionId = coalitionId + self.stock = { fuelKg = (stock and stock.fuelKg) or 0, items = {} } + for name, count in pairs((stock and stock.items) or {}) do + self.stock.items[name] = count + end + self.state = CTLDFatCow.STATE_STOWED + self.scene = nil + self.farpName = nil +end + +function CTLDFatCow:isEmpty() + if (self.stock.fuelKg or 0) > 0 then return false end + for _, count in pairs(self.stock.items) do + if count > 0 then return false end + end + return true +end + +--- Sets every weapon the warehouse lists to 0 (pcall: getInventory is an observation, not a contract). +local function clearWeapons(w) + local ok, inv = pcall(w.getInventory, w) + for name in pairs(ok and inv and inv.weapon or {}) do + pcall(w.setItem, w, name, 0) + end +end + +--- Writes the stock into a FARP warehouse: jet fuel, every other liquid at 0, each stocked item, and +-- every other weapon the warehouse lists at 0. A weapon getInventory does not list is not reached: +-- a mission FARP measured with an unlimited stock listed none (ticket 06); a refilled cow FARP is to measure. +function CTLDFatCow:writeWarehouse(w) + clearWeapons(w) + w:setLiquidAmount(JET_FUEL, self.stock.fuelKg) + for t = JET_FUEL + 1, LIQUID_LAST do + w:setLiquidAmount(t, 0) + end + for name, count in pairs(self.stock.items) do + w:setItem(name, count) + end +end + +--- Empties a FARP warehouse: every liquid and every weapon it lists. +function CTLDFatCow.emptyAnyWarehouse(w) + clearWeapons(w) + for t = JET_FUEL, LIQUID_LAST do + w:setLiquidAmount(t, 0) + end +end + +--- Empties a FARP warehouse: a packed cow's FARP stays in the world, with nothing in it. +function CTLDFatCow:emptyWarehouse(w) + CTLDFatCow.emptyAnyWarehouse(w) + for name in pairs(self.stock.items) do + w:setItem(name, 0) + end +end + +--- Reads the stock back from the FARP warehouse: what the players took is gone. A count that went up +-- was refilled by someone else (VMCT's GRASS refills every FARP on each player birth, evening run +-- 2026-10-26): the cow keeps its own count, and the next write puts it back. What was taken between +-- the last read and such a refill is not seen, so the stock stays at most what it was at that read. +function CTLDFatCow:readWarehouse(w) + self.stock.fuelKg = math.min(self.stock.fuelKg, w:getLiquidAmount(JET_FUEL) or 0) + for name, count in pairs(self.stock.items) do + self.stock.items[name] = math.min(count, w:getItemCount(name) or 0) + end +end + +--- Writes back what the server's warehouse holds. Ticket 01 did not measure whether a client sees +-- the server's warehouse; VMCT re-applies FARP warehouses on every birth for that reason. +function CTLDFatCow:resyncWarehouse(w) + self:readWarehouse(w) + self:writeWarehouse(w) +end + +-- ── Cow crates (piloted cow) ──────────────────────────────────────────────────────────────────── +-- A cow crate's descriptor carries `fatCow = { fuelKg }` or `fatCow = { item, count }`: what it holds +-- when full. Once it has served, crate.metadata.fatCow holds what is left and crate.metadata.massKg +-- what it weighs. + +local function contentOf(crate) + return crate.metadata and crate.metadata.fatCow or crate.descriptor.fatCow +end + +local function isCowCrate(crate) + return crate and crate.descriptor and crate.descriptor.fatCow ~= nil +end + +--- What the players read for a warehouse item; an item not listed shows its DCS name. +-- Literal ctld.tr calls, so the dictionary generator sees each label. +local function itemLabel(name) + if name == "weapons.missiles.AGM_114K" then return ctld.tr("laser Hellfire") end + if name == "weapons.missiles.AGM_114" then return ctld.tr("radar Hellfire") end + if name == "weapons.shells.M230_HEDP M789" then return ctld.tr("30 mm rounds") end + if name == "weapons.missiles.Vikhr_M" then return ctld.tr("Vikhr") end + if name == "weapons.missiles.Ataka_9M120" then return ctld.tr("Ataka") end + if name == "weapons.missiles.AT_6" then return ctld.tr("Shturm") end + if name == "weapons.shells.2A42_30_HE" then return ctld.tr("2A42 HE rounds") end + if name == "weapons.shells.2A42_30_AP" then return ctld.tr("2A42 AP rounds") end + if name == "weapons.shells.GSh_30_2K_HE" then return ctld.tr("GSh-30 rounds") end + return (name:gsub("^weapons%.[%a_]+%.", "")) +end + +--- "2400 kg of fuel, laser Hellfire x4" — for the players. +function CTLDFatCow.describeStock(stock) + local parts = { ctld.tr("%1 kg of fuel", math.floor((stock.fuelKg or 0) + 0.5)) } + local names = {} + for name in pairs(stock.items or {}) do names[#names + 1] = name end + table.sort(names) + for _, name in ipairs(names) do + parts[#parts + 1] = string.format("%s x%d", itemLabel(name), stock.items[name]) + end + return table.concat(parts, ", ") +end + +--- Sums the cow crates of a list (other crates are ignored) into one stock. +function CTLDFatCow.stockOfCrates(crates) + local stock = { fuelKg = 0, items = {} } + for _, crate in ipairs(crates) do + if isCowCrate(crate) then + local c = contentOf(crate) + if c.fuelKg then + stock.fuelKg = stock.fuelKg + c.fuelKg + elseif c.item then + stock.items[c.item] = (stock.items[c.item] or 0) + (c.count or 0) + end + end + end + return stock +end + +--- Puts what is left of a stock back into the cow crates it came from, in order, each up to what it +-- held when the FARP opened. Sets each crate's metadata (content and mass). +-- @return the crates left empty, for the caller to remove +function CTLDFatCow.refillCrates(stock, crates) + local fuelLeft = stock.fuelKg or 0 + local itemsLeft = {} + for name, count in pairs(stock.items or {}) do itemsLeft[name] = count end + local emptied = {} + for _, crate in ipairs(crates) do + if isCowCrate(crate) then + local full = crate.descriptor.fatCow + local before = contentOf(crate) + local now, fraction + if before.fuelKg then + local kg = math.min(before.fuelKg, fuelLeft) + fuelLeft = fuelLeft - kg + now = { fuelKg = kg } + fraction = full.fuelKg > 0 and kg / full.fuelKg or 0 + else + local n = math.min(before.count or 0, itemsLeft[before.item] or 0) + itemsLeft[before.item] = (itemsLeft[before.item] or 0) - n + now = { item = before.item, count = n } + fraction = (full.count or 0) > 0 and n / full.count or 0 + end + crate.metadata.fatCow = now + crate.metadata.massKg = crate.descriptor.weight * fraction + if fraction <= 0 then emptied[#emptied + 1] = crate end + end + end + return emptied +end + +-- ==================================================================================================== +-- CTLDFatCowManager +-- ==================================================================================================== + +CTLDFatCowManager = class() +CTLDFatCowManager._instance = nil + +CTLDFatCowManager.SCENE_NAME = "Fat Cow FARP" + +function CTLDFatCowManager.getInstance() + if not CTLDFatCowManager._instance then + local o = setmetatable({}, CTLDFatCowManager) + o:init() + CTLDFatCowManager._instance = o + end + return CTLDFatCowManager._instance +end + +function CTLDFatCowManager:init() + self._cows = {} -- unit name → CTLDFatCow + self._farpNames = {} -- unit name → the name of its FARP, kept across deployments + self._freeFarps = {} -- FARP names of AI cows gone, for the next cow to reuse +end + +--- The cow's own FARP name: one heliport per cow, added again at each deployment and never destroyed. +-- "CTLD_" so other scripts can tell a cow FARP from a mission FARP. A name an AI cow left is reused, +-- so the map does not gain an empty FARP with every cow requested. +function CTLDFatCowManager:_farpNameFor(unitName) + if not self._farpNames[unitName] then + local name = table.remove(self._freeFarps) + if not name then + self._farpCounter = (self._farpCounter or 0) + 1 + name = string.format("CTLD_FatCow_FARP_%d", self._farpCounter) + end + self._farpNames[unitName] = name + end + return self._farpNames[unitName] +end + +--- An AI cow is gone for good (despawned or dead): its FARP name goes back to the pool. +function CTLDFatCowManager:_releaseFarpName(unitName) + local name = self._farpNames[unitName] + if not name then return end + self._farpNames[unitName] = nil + self._freeFarps[#self._freeFarps + 1] = name +end + +--- Declares a unit a cow with the given stock (replaces a previous declaration of the same unit). +-- @return CTLDFatCow +function CTLDFatCowManager:addCow(unit, stock) + local cow = CTLDFatCow:new(unit:getName(), unit:getCoalition(), stock) + self._cows[cow.unitName] = cow + return cow +end + +function CTLDFatCowManager:getCow(unitName) + return self._cows[unitName] +end + +--- Packs the cow if it is deployed, then forgets it. +function CTLDFatCowManager:removeCow(unitName) + if not self._cows[unitName] then return end + self:pack(unitName) + self._cows[unitName] = nil +end + +local function warehouseOf(farpName) + local ab = farpName and Airbase.getByName(farpName) + return ab and ab:getWarehouse() or nil +end + +--- Lays out the Fat Cow scene at the cow. The warehouse is written when the scene is laid out +-- (onSceneLaidOut), not here: the FARP does not exist yet. +-- @return true, or false and a reason: "unknown", "airborne", "deployed", "empty", "scene" (not started), +-- "failed" (its FARP or fuel depot could not be added: the cow is stowed and its audience told) +function CTLDFatCowManager:deploy(unit) + local cow = self._cows[unit:getName()] + if not cow then return false, "unknown" end + if unit:inAir() then return false, "airborne" end + if cow.state ~= CTLDFatCow.STATE_STOWED then return false, "deployed" end + if cow:isEmpty() then return false, "empty" end + cow.state = CTLDFatCow.STATE_DEPLOYING + local scene = CTLDSceneManager.getInstance():playScene(unit, CTLDFatCowManager.SCENE_NAME, + { fatCow = cow, farpName = self:_farpNameFor(cow.unitName) }) + if not scene then + cow.state = CTLDFatCow.STATE_STOWED + return false, "scene" + end + -- The first step runs at once: a FARP that could not be added has already stowed the cow and said so. + if cow.state == CTLDFatCow.STATE_STOWED then return false, "failed" end + cow.scene = scene + return true +end + +--- Called by the scene's last step: the FARP exists, its warehouse gets the stock. +function CTLDFatCowManager:onSceneLaidOut(scene) + local cow = scene._params and scene._params.fatCow + if not cow then return end + cow.scene = scene + cow.farpName = scene._params.farpName + local w = warehouseOf(cow.farpName) + if not w then + ctld.utils.log("ERROR", "CTLDFatCowManager: no warehouse for FARP '%s' of cow '%s'", + tostring(cow.farpName), cow.unitName) + self:onSceneFailed(scene) + return + end + cow:writeWarehouse(w) + cow.state = CTLDFatCow.STATE_DEPLOYED + ctld.utils.log("INFO", "CTLDFatCowManager: cow '%s' deployed, FARP '%s', %d kg of fuel", + cow.unitName, cow.farpName, cow.stock.fuelKg) + self:_notify(cow, ctld.tr("Fat Cow FARP open: %1", CTLDFatCow.describeStock(cow.stock))) +end + +--- Called by the scene when its FARP or its fuel depot could not be added: the cow is stowed again +-- (its FARP, if added, stays empty). +function CTLDFatCowManager:onSceneFailed(scene) + local cow = scene._params and scene._params.fatCow + if not cow then return end + CTLDSceneManager.getInstance():packScene(scene) + local w = warehouseOf(scene._params.farpName) + if w then CTLDFatCow.emptyAnyWarehouse(w) end + cow.scene, cow.farpName = nil, nil + cow.state = CTLDFatCow.STATE_STOWED + ctld.utils.log("ERROR", "CTLDFatCowManager: FARP of cow '%s' not added", cow.unitName) + self:_notify(cow, ctld.tr("The Fat Cow FARP could not be deployed.")) +end + +--- Tells the cow's audience (its own group when piloted, the escorted flight when AI). +function CTLDFatCowManager:_notify(cow, text) + if cow.notifyGroupId then + trigger.action.outTextForGroup(cow.notifyGroupId, text, 15) + end +end + +--- Reads what the players left back into the stock, empties the FARP, then removes the scene (the +-- FARP itself stays: see the scene's first step). +-- @return true when a deployed cow was packed +function CTLDFatCowManager:pack(unitName) + local cow = self._cows[unitName] + if not cow or cow.state == CTLDFatCow.STATE_STOWED then return false end + if cow.state == CTLDFatCow.STATE_DEPLOYED then + local w = warehouseOf(cow.farpName) + if w then + cow:readWarehouse(w) + cow:emptyWarehouse(w) + end + else + -- Still laying out: the FARP may exist already, its warehouse never written. Emptied, not read. + local w = warehouseOf(cow.scene and cow.scene._params and cow.scene._params.farpName) + if w then CTLDFatCow.emptyAnyWarehouse(w) end + end + if cow.scene then + -- A scene still laying out would go on spawning after the pack: stop it first. + if cow.state == CTLDFatCow.STATE_DEPLOYING and cow.scene.abort then + cow.scene:abort("Fat Cow packed while deploying") + end + CTLDSceneManager.getInstance():packScene(cow.scene) + end + cow.scene, cow.farpName = nil, nil + cow.state = CTLDFatCow.STATE_STOWED + ctld.utils.log("INFO", "CTLDFatCowManager: cow '%s' packed, %d kg of fuel left", + cow.unitName, cow.stock.fuelKg) + return true +end + +--- S_EVENT_BIRTH: a player arriving gets every cow FARP re-written (see _resyncFarps), now and again a +-- moment later: a script refilling FARPs on the same birth (VMCT) may run before or after CTLD. +function CTLDFatCowManager:onBirth(event) + if self._started and next(ctld.gs("fatCowAICows") or {}) then self:_activateMissionCows() end + local obj = event and event.initiator + if not (obj and obj.getPlayerName) then return end + local ok, playerName = pcall(obj.getPlayerName, obj) + if not (ok and playerName) then return end + -- A pilot taking the slot of a piloted cow whose FARP was left open (its pilot left the slot): the + -- FARP closes, emptied, so the new pilot starts from a clean hold instead of "already deployed" + -- and a pack that no longer finds the previous pilot's crates. + local name = ctld.utils.safeObjectName(obj) + local left = name and self._cows[name] + if left and left.crates then self:removeCow(name) end + self:_resyncFarps() + local mgr = self + ctld.scheduler.schedule(function() mgr:_resyncFarps() end, nil, timer.getTime() + 2) +end + +-- How often every cow FARP is read and re-written: a refill run before CTLD reads hides what was taken +-- since the last read (see readWarehouse), so the reads are kept close together. +local RESYNC_PERIOD_S = 10 + +--- Every cow FARP: an open one gets its stock read and written back (see resyncWarehouse), any other is +-- emptied (it stays in the world, and a refill would make it serve next to another cow's depot). +function CTLDFatCowManager:_resyncFarps() + local open = {} + for _, cow in pairs(self._cows) do + if cow.state == CTLDFatCow.STATE_DEPLOYED then + open[cow.farpName or ""] = true + local w = warehouseOf(cow.farpName) + if w then cow:resyncWarehouse(w) end + elseif cow.state == CTLDFatCow.STATE_DEPLOYING then + open[(cow.scene and cow.scene._params and cow.scene._params.farpName) or ""] = true + end + end + local names = {} + for _, name in pairs(self._farpNames) do names[#names + 1] = name end + for _, name in ipairs(self._freeFarps) do names[#names + 1] = name end + for _, name in ipairs(names) do + if not open[name] then + local w = warehouseOf(name) + if w then CTLDFatCow.emptyAnyWarehouse(w) end + end + end +end + +-- ==================================================================================================== +-- Start-up: crate descriptors and DCS events (called by CTLDCoreManager:init when fatCowEnabled) +-- ==================================================================================================== + +function CTLDFatCowManager:start() + if self._started then return end + self._started = true + self:_injectCrateDescriptors() + local bridge = CTLDDCSEventBridge.getInstance() + bridge:register(self, world.event.S_EVENT_BIRTH, "onBirth") + bridge:register(self, world.event.S_EVENT_TAKEOFF, "onTakeoff") + bridge:register(self, world.event.S_EVENT_LAND, "onLand") + bridge:register(self, world.event.S_EVENT_DEAD, "onDead") + bridge:register(self, world.event.S_EVENT_CRASH, "onDead") + bridge:register(self, world.event.S_EVENT_PLAYER_LEAVE_UNIT, "onPlayerLeave") + self:_activateMissionCows() + local mgr = self + ctld.scheduler.register("fatCow_resync", ctld.scheduler.schedule(function(_, now) + mgr:_resyncFarps() + return now + RESYNC_PERIOD_S + end, nil, timer.getTime() + RESYNC_PERIOD_S)) + pcall(self._logFarpWarehouses, self) + ctld.utils.log("INFO", "CTLDFatCowManager: started, %d cow crate type(s)", #self._crateDescs) +end + +--- One log line per mission FARP (10 at most): what DCS reports for its warehouse. A measurement, before +-- "FARP status" covers every FARP and not only Fat Cow ones: what an unlimited warehouse reads as is unknown. +function CTLDFatCowManager:_logFarpWarehouses() + local n = 0 + for _, ab in ipairs(world.getAirbases() or {}) do + local okD, desc = pcall(ab.getDesc, ab) + if okD and desc and desc.category == Airbase.Category.HELIPAD and n < 10 then + n = n + 1 + local okW, w = pcall(ab.getWarehouse, ab) + if okW and w then + local okI, inv = pcall(w.getInventory, w) + local nW = 0 + for _ in pairs(okI and inv and inv.weapon or {}) do nW = nW + 1 end + ctld.utils.log("INFO", "CTLDFatCowManager: FARP warehouse '%s': jet fuel %s, AGM_114K %s, %d weapon type(s)", + ab:getName(), tostring(w:getLiquidAmount(0)), tostring(w:getItemCount("weapons.missiles.AGM_114K")), nW) + end + end + end +end + +--- Turns fatCowCrates into crate descriptors the crate system knows (indexed by weight, as every +-- crate is), but keeps them out of the shared Request Equipment menu: the cow requests its own. +function CTLDFatCowManager:_injectCrateDescriptors() + local cm = CTLDCrateManager.getInstance() + self._crateDescs = {} + self._kgPerItem = {} + for _, def in ipairs(ctld.gs("fatCowCrates") or {}) do + local content = ((def.fuelKg or 0) > 0 and { fuelKg = def.fuelKg }) + or (def.item and (def.count or 0) > 0 and { item = def.item, count = def.count }) + if not (content and def.desc and (def.weight or 0) > 0) then + ctld.utils.log("WARN", "CTLDFatCowManager: fatCowCrates entry '%s' ignored: it needs desc, weight, and fuelKg or item + count", + tostring(def.desc)) + else + if content.item then self._kgPerItem[content.item] = def.weight / content.count end + local d = cm:registerDescriptor({ + weight = def.weight, + desc = ctld.tr(def.desc), + unit = "FatCow:" .. def.desc, + cratesRequired = 1, + showSets = false, + fatCow = content, + }) + self._crateDescs[#self._crateDescs + 1] = d + end + end +end + +function CTLDFatCowManager:isCowType(typeName) + for _, t in ipairs(ctld.gs("fatCowTypes") or {}) do + if t == typeName then return true end + end + return false +end + +-- ==================================================================================================== +-- Piloted cow (ticket 03): F10 > CTLD > Fat Cow, for the types listed in fatCowTypes +-- ==================================================================================================== + +--- The cow crates in a unit's hold. +local function cowCratesOnboard(unitName) + local out = {} + for _, crate in ipairs(CTLDCrateManager.getInstance():cratesOnboard(unitName)) do + if crate.descriptor and crate.descriptor.fatCow then out[#out + 1] = crate end + end + return out +end + +function CTLDFatCowManager:requestCrate(unitName, weight) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = ctld.utils.getGroupId(t) + if ctld.utils.inAir(t) then + trigger.action.outTextForGroup(gid, ctld.tr("You must be landed to request a crate."), 10) + return + end + local zones = CTLDZoneManager.getInstance():getLogisticZonesAtPoint(t:getPoint(), t:getCoalition(), "cratesPickup") + if not next(zones) then + trigger.action.outTextForGroup(gid, ctld.tr("You are not close enough to friendly logistics to get a crate!"), 10) + return + end + local desc + for _, d in ipairs(self._crateDescs or {}) do + if d.weight == weight then desc = d end + end + if not desc then return end + local count = CTLDCrateManager.getInstance():spawnCratesAligned({ desc }, t, t:getCoalition(), unitName, + CTLDCrate.SPAWN_METHOD.MENU_CTLD) + if (count or 0) < 1 then + trigger.action.outTextForGroup(gid, ctld.tr("Request failed: the equipment could not be brought out."), 10) + return + end + trigger.action.outTextForGroup(gid, ctld.tr("%1 is next to you: load it into your hold.", desc.desc), 10) +end + +--- In a friendly logistic zone that serves crates, on the ground: true, or false after telling why. +local function atLogistics(t, gid) + if ctld.utils.inAir(t) then + trigger.action.outTextForGroup(gid, ctld.tr("You must be landed to request a crate."), 10) + return false + end + local zones = CTLDZoneManager.getInstance():getLogisticZonesAtPoint(t:getPoint(), t:getCoalition(), "cratesPickup") + if not next(zones) then + trigger.action.outTextForGroup(gid, ctld.tr("You are not close enough to friendly logistics to get a crate!"), 10) + return false + end + return true +end + +--- Splits a fatCowLoadouts entry into the cow crates that carry it: its fuel into fuel tanks, each item +-- into the crates of that item, every crate full but the last of each kind. Matching by content, not by +-- crate name: CTLDConfig translates every desc (evening run, 2026-10-26: refused on the French server). +-- @return list of { desc, content, fraction }, or nil and what no crate carries +function CTLDFatCowManager:cratesForLoadout(loadout) + local fuelDesc, itemDesc = nil, {} + for _, d in ipairs(self._crateDescs or {}) do + if d.fatCow.fuelKg then + fuelDesc = fuelDesc or d + else + itemDesc[d.fatCow.item] = itemDesc[d.fatCow.item] or d + end + end + local out = {} + local function split(d, total, makeContent, capacity) + while total > 0 do + local n = math.min(capacity, total) + out[#out + 1] = { desc = d, content = makeContent(n), fraction = n / capacity } + total = total - n + end + end + if (loadout.fuelKg or 0) > 0 then + if not fuelDesc then return nil, "fuel" end + split(fuelDesc, loadout.fuelKg, function(n) return { fuelKg = n } end, fuelDesc.fatCow.fuelKg) + end + for _, it in ipairs(loadout.items or {}) do + local d = itemDesc[it.item] + if not d then return nil, tostring(it.item) end + split(d, it.count or 0, function(n) return { item = it.item, count = n } end, d.fatCow.count) + end + return out +end + +--- F10 "Load" > a loadout: a fatCowLoadouts entry, straight into the hold. +function CTLDFatCowManager:loadLoadout(unitName, index) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = ctld.utils.getGroupId(t) + local loadout = (ctld.gs("fatCowLoadouts") or {})[index] + if not (loadout and atLogistics(t, gid)) then return end + local wanted, missing = self:cratesForLoadout(loadout) + if not wanted then + ctld.utils.log("WARN", "CTLDFatCowManager: loadout '%s' needs '%s', which no fatCowCrates entry carries", + tostring(loadout.desc), missing) + trigger.action.outTextForGroup(gid, ctld.tr("This loadout cannot be loaded."), 10) + return + end + local cm = CTLDCrateManager.getInstance() + local caps = (ctld.gs("capabilitiesByType") or {})[t:getTypeName()] or {} + local capacity = caps.maxCratesOnboard or 1 + local onboard = #cm:cratesOnboard(unitName) + if onboard + #wanted > capacity then + trigger.action.outTextForGroup(gid, + ctld.tr("Not enough room: %1 crate(s) on board, %2 more needed, %3 at most.", onboard, #wanted, capacity), 10) + return + end + local loaded = {} + for _, w in ipairs(wanted) do + local crate = cm:loadIntoHold(w.desc, t, unitName) + if w.fraction < 1 then + crate.metadata.fatCow = w.content + crate.metadata.massKg = w.desc.weight * w.fraction + end + loaded[#loaded + 1] = crate + end + ctld.utils.updateTransportWeight(unitName) + trigger.action.outTextForGroup(gid, + ctld.tr("Loaded into your hold: %1", CTLDFatCow.describeStock(CTLDFatCow.stockOfCrates(loaded))), 10) +end + +--- F10 "Unload the Fat Cow crates": back to the logistic zone, to make up another loadout. +function CTLDFatCowManager:unloadHold(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = ctld.utils.getGroupId(t) + local cow = self._cows[unitName] + if cow and cow.state ~= CTLDFatCow.STATE_STOWED then + trigger.action.outTextForGroup(gid, ctld.tr("The Fat Cow FARP is already deployed."), 10) + return + end + if not atLogistics(t, gid) then return end + local cm = CTLDCrateManager.getInstance() + local crates = cowCratesOnboard(unitName) + for _, crate in ipairs(crates) do cm:destroyCrate(crate.crateName) end + ctld.utils.updateTransportWeight(unitName) + trigger.action.outTextForGroup(gid, ctld.tr("%1 Fat Cow crate(s) unloaded.", #crates), 10) +end + +--- Deploys the FARP with the cow crates on board as its stock. The crates stay on board. +function CTLDFatCowManager:deployFromHold(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = ctld.utils.getGroupId(t) + local existing = self._cows[unitName] + if existing and existing.state ~= CTLDFatCow.STATE_STOWED then + trigger.action.outTextForGroup(gid, ctld.tr("The Fat Cow FARP is already deployed."), 10) + return + end + if ctld.utils.inAir(t) then + trigger.action.outTextForGroup(gid, ctld.tr("Land to deploy the Fat Cow FARP."), 10) + return + end + local crates = cowCratesOnboard(unitName) + if #crates == 0 then + trigger.action.outTextForGroup(gid, ctld.tr("No Fat Cow crate on board: load a fuel tank or an ammo crate first."), 10) + return + end + local cow = self:addCow(t, CTLDFatCow.stockOfCrates(crates)) + cow.crates = crates + cow.notifyGroupId = gid + local ok, why = self:deploy(t) + if not ok then + self._cows[unitName] = nil + if why == "failed" then return end -- onSceneFailed has said so + local text = (why == "empty") and ctld.tr("The Fat Cow crates on board are empty.") + or ctld.tr("The Fat Cow FARP could not be deployed.") + trigger.action.outTextForGroup(gid, text, 10) + return + end + trigger.action.outTextForGroup(gid, ctld.tr("Deploying the Fat Cow FARP..."), 10) +end + +--- Packs a piloted cow: what is left goes back into its crates, emptied crates are removed. +-- @param quiet boolean no "nothing to pack" message (take-off) +function CTLDFatCowManager:packIntoHold(unitName, quiet) + local cow = self._cows[unitName] + local t = Unit.getByName(unitName) + local gid = t and t:isExist() and ctld.utils.getGroupId(t) + if not (cow and cow.crates and self:pack(unitName)) then + if gid and not quiet then + trigger.action.outTextForGroup(gid, ctld.tr("No Fat Cow FARP deployed."), 10) + end + return + end + -- Every crate the FARP was opened with gets its share back, wherever it is now (one unloaded + -- while the FARP was open included); a crate destroyed meanwhile is gone with its share. + local cm = CTLDCrateManager.getInstance() + local stillThere = {} + for _, crate in ipairs(cow.crates) do + if cm.crates[crate.crateName] == crate then stillThere[#stillThere + 1] = crate end + end + for _, crate in ipairs(CTLDFatCow.refillCrates(cow.stock, stillThere)) do + cm:destroyCrate(crate.crateName) + end + ctld.utils.updateTransportWeight(unitName) + self._cows[unitName] = nil + if gid then + trigger.action.outTextForGroup(gid, ctld.tr("Fat Cow FARP packed: %1 left.", CTLDFatCow.describeStock(cow.stock)), 10) + end +end + +-- @param toGroupId number|nil who is told (default: the cow's own group) +function CTLDFatCowManager:reportStock(unitName, toGroupId) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local cow = self._cows[unitName] + local stock + if cow and cow.state == CTLDFatCow.STATE_DEPLOYED then + local w = warehouseOf(cow.farpName) + if w then cow:readWarehouse(w) end + stock = cow.stock + else + stock = CTLDFatCow.stockOfCrates(cowCratesOnboard(unitName)) + end + trigger.action.outTextForGroup(toGroupId or ctld.utils.getGroupId(t), ctld.tr("Fat Cow stock: %1", CTLDFatCow.describeStock(stock)), 15) +end + +--- S_EVENT_TAKEOFF: a piloted cow taking off packs its FARP (AI cows: see onTakeoff below). +function CTLDFatCowManager:onTakeoff(event) + local unit = event and event.initiator + local name = ctld.utils.safeObjectName(unit) + if not name then return end + local cow = self._cows[name] + if cow and cow.crates and cow.state ~= CTLDFatCow.STATE_STOWED then + self:packIntoHold(name, true) + end + self:_onAITakeoff(name) +end + +--- S_EVENT_DEAD / S_EVENT_CRASH: a dead cow's FARP goes with it. Any loss (and a player leaving, +-- below) may also leave an AI cow with no flight to escort: checked a moment later, once DCS has +-- removed the unit. +function CTLDFatCowManager:onDead(event) + local name = ctld.utils.safeObjectName(event and event.initiator) + if name and self._cows[name] then + local wasAI = self._cows[name].ai ~= nil + self:removeCow(name) + self:_forgetAICow(name) + if wasAI then self:_releaseFarpName(name) end -- a piloted slot keeps its FARP for the next pilot + end + self:_checkFlightsLater() +end + +function CTLDFatCowManager:onPlayerLeave(_) + self:_checkFlightsLater() +end + +function CTLDFatCowManager:_checkFlightsLater() + if not next(self._aiByFlight or {}) then return end + local mgr = self + ctld.scheduler.schedule(function() mgr:_sendHomeOrphans() end, nil, timer.getTime() + 2) +end + +--- An AI cow whose flight is no longer in the world packs and goes home. +function CTLDFatCowManager:_sendHomeOrphans() + for flight, cow in pairs(self._aiByFlight or {}) do + local g = Group.getByName(flight) + if not (g and g:isExist() and g:getUnit(1)) then + self:_returnHome(cow) + end + end +end + +-- ── Menu section ──────────────────────────────────────────────────────────────────────────────── + +--- One command per fatCowLoadouts entry under `path`, calling manager method `method`(unitName, index): +-- the entries without a `menu` at the top (the Apache ones), the others in the Russian and Mixed +-- submenus (Zip: one CH-47 for everyone, the Russian and mixed loadouts one level down). +function CTLDFatCowManager:_addLoadoutCommands(menu, path, unitName, method) + local subs = { russian = { ctld.tr("Russian"), 98 }, mixed = { ctld.tr("Mixed"), 99 } } + local made = {} + local mgr = CTLDFatCowManager.getInstance + for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do + local where, sub = path, l.menu and subs[l.menu] + if l.menu and not sub then + ctld.utils.log("WARN", "CTLDFatCowManager: loadout '%s' has an unknown menu '%s': shown at the top", + tostring(l.desc), tostring(l.menu)) + end + if sub then + where = {} + for k, v in ipairs(path) do where[k] = v end + where[#where + 1] = sub[1] + if not made[l.menu] then + menu:addSubMenu(path, sub[1], { order = sub[2] }) + made[l.menu] = true + end + end + menu:addCommand(where, ctld.tr(l.desc or "?"), + function(arg) local m = mgr(); m[arg.method](m, arg.unitName, arg.index) end, + { unitName = unitName, index = i, method = method }, { order = i }) + end +end + +function CTLDFatCowManager:buildMenuSection(playerObj, menu) + local root, sub = ctld.tr("CTLD"), ctld.tr("Fat Cow") + local path = { root, sub } + if playerObj.isTransport and self:isCowType(playerObj.typeName) then + menu:addSubMenu({ root }, sub, { order = 50 }) + local mgr = CTLDFatCowManager.getInstance + local loadPath, cratePath = { root, sub, ctld.tr("Load") }, { root, sub, ctld.tr("Request a crate") } + menu:addSubMenu(path, loadPath[3], { order = 1 }) + self:_addLoadoutCommands(menu, loadPath, playerObj.unitName, "loadLoadout") + menu:addSubMenu(path, cratePath[3], { order = 2 }) + for i, d in ipairs(self._crateDescs or {}) do + menu:addCommand(cratePath, d.desc, + function(arg) mgr():requestCrate(arg.unitName, arg.weight) end, + { unitName = playerObj.unitName, weight = d.weight }, { order = i }) + end + menu:addCommand(path, ctld.tr("Unload the Fat Cow crates"), + function(arg) mgr():unloadHold(arg.unitName) end, { unitName = playerObj.unitName }, { order = 19 }) + menu:addCommand(path, ctld.tr("Deploy the Fat Cow FARP"), + function(arg) mgr():deployFromHold(arg.unitName) end, { unitName = playerObj.unitName }, { order = 20 }) + menu:addCommand(path, ctld.tr("Pack the Fat Cow FARP"), + function(arg) mgr():packIntoHold(arg.unitName) end, { unitName = playerObj.unitName }, { order = 21 }) + menu:addCommand(path, ctld.tr("Fat Cow stock"), + function(arg) mgr():reportStock(arg.unitName) end, { unitName = playerObj.unitName }, { order = 22 }) + menu:addCommand(path, ctld.tr("FARP status"), + function(arg) mgr():reportFarpStatus(arg.unitName) end, { unitName = playerObj.unitName }, { order = 23 }) + else + self:_buildAIMenu(playerObj, menu, path) + end +end + +-- The section's manager resolves the singleton at build time, so a reset instance is never reached. +CTLDPlayerManager.deferMenuSection({ + key = "fatCow", + manager = { buildMenuSection = function(_, playerObj, menu) + CTLDFatCowManager.getInstance():buildMenuSection(playerObj, menu) + end }, + method = "buildMenuSection", + configKey = "fatCowEnabled", + order = 50, +}) + +-- ==================================================================================================== +-- AI cow (ticket 04): requested by a flight on the ground in a logistic zone, or placed in the +-- mission and declared in fatCowAICows. It follows the flight, lands next to its leader, deploys +-- after fatCowDeployDelay, packs when the leader takes off, and goes home when empty or released. +-- ==================================================================================================== + +CTLDFatCowManager.PHASE_FOLLOWING = "following" +CTLDFatCowManager.PHASE_LANDING = "landing" -- sent to land next to the leader +CTLDFatCowManager.PHASE_LANDED = "landed" -- on the ground, deployment pending or done +CTLDFatCowManager.PHASE_RETURNING = "returning" -- going home to despawn +CTLDFatCowManager.PHASE_HOLDING = "holding" -- orbiting, on "Take off" or "Hold here" + +-- Internal fuel given to a scripted cow: 80 % of the CH-47Fbl1's M_fuel_max (3054.6 kg in DCS's +-- db.Units.lua), what a CH-47 usually flies with (Zip); the loadouts are sized for it. A scripted +-- aircraft created without fuel falls out of the sky. +local AI_COW_FUEL_KG = 2444 +local FOLLOW_OFFSET = { x = -200, y = 60, z = 150 } -- behind, above and to the right of the leader +local LAND_OFFSET_M = 120 -- landing point, to the leader's left +local CRUISE_SPEED_MPS = 60 +local CRUISE_ALT_M = 150 +local ORBIT_SPEED_MPS = 40 +-- Cruise speed used for the joining estimate: an assumption (about 235 km/h), to check in game. +local AI_COW_CRUISE_MPS = 65 +-- How long a Land task keeps the cow down: a day, so it never lifts off on its own while serving. +-- The flight's take-off or a release re-tasks it long before. +local LAND_DURATION_S = 86400 + +--- The stock of a fatCowLoadouts entry, or nil when there is no such entry. +local function loadoutStock(index) + local l = (ctld.gs("fatCowLoadouts") or {})[index] + if not l then return nil end + local stock = { fuelKg = l.fuelKg or 0, items = {} } + for _, it in ipairs(l.items or {}) do + if it.item and it.count then stock.items[it.item] = (stock.items[it.item] or 0) + it.count end + end + return stock +end + +local function controllerOf(groupName) + local g = Group.getByName(groupName) + return g and g:isExist() and g:getController() or nil +end + +local function firstUnitOf(groupName) + local g = Group.getByName(groupName) + return g and g:isExist() and g:getUnit(1) or nil +end + +-- A route to `point` whose single turning point carries a Land task: VMCT measured that a scripted +-- helicopter given a Land *waypoint* lands on the nearest airfield instead (known limitation +-- helicopter-land-waypoint-goes-to-the-nearest-airfield). +local function landMission(point) + return { + id = "Mission", + params = { route = { points = { { + type = "Turning Point", action = "Turning Point", + x = point.x, y = point.z, alt = CRUISE_ALT_M, alt_type = "RADIO", + speed = CRUISE_SPEED_MPS, speed_locked = true, + task = { id = "ComboTask", params = { tasks = { { + number = 1, auto = false, enabled = true, id = "Land", + params = { point = { x = point.x, y = point.z }, durationFlag = true, duration = LAND_DURATION_S }, + } } } }, + } } } }, + } +end + +-- What a stock weighs: its fuel, and each item at its crate's mass per item (50 kg for an item no +-- crate carries, about a missile). +function CTLDFatCowManager:massOf(stock) + local kg = stock.fuelKg or 0 + for item, count in pairs(stock.items) do + kg = kg + count * ((self._kgPerItem or {})[item] or 50) + end + return kg +end + +local function setCargoMass(cow) + pcall(trigger.action.setUnitInternalCargo, cow.unitName, CTLDFatCowManager.getInstance():massOf(cow.stock)) +end + +--- Registers an AI cow already in the world. +-- @param groupName string the cow's group +-- @param flightGroup string the group it escorts +-- @param homePoint vec3 where it goes back to despawn +-- @param loadoutIndex number the fatCowLoadouts entry it carries +function CTLDFatCowManager:addAICow(groupName, flightGroup, homePoint, loadoutIndex) + local unit = firstUnitOf(groupName) + local stock = loadoutStock(loadoutIndex) + if not (unit and stock) then return nil end + local cow = self:addCow(unit, stock) + cow.ai = { groupName = groupName, flightGroup = flightGroup, home = homePoint, + phase = CTLDFatCowManager.PHASE_FOLLOWING } + self._aiByFlight = self._aiByFlight or {} + self._aiByFlight[flightGroup] = cow + setCargoMass(cow) + self:_follow(cow) + return cow +end + +function CTLDFatCowManager:aiCowOfFlight(flightGroup) + return self._aiByFlight and self._aiByFlight[flightGroup] or nil +end + +function CTLDFatCowManager:_follow(cow) + local flight = Group.getByName(cow.ai.flightGroup) + local ctrl = controllerOf(cow.ai.groupName) + if not (flight and flight:isExist() and ctrl) then return false end + ctrl:setTask({ id = "Follow", params = { groupId = flight:getID(), pos = FOLLOW_OFFSET, lastWptIndexFlag = false } }) + cow.ai.phase = CTLDFatCowManager.PHASE_FOLLOWING + return true +end + +function CTLDFatCowManager:_landNextTo(cow, leader) + local ctrl = controllerOf(cow.ai.groupName) + if not ctrl then return end + local p = leader:getPoint() + local hdg = ctld.utils.getHeadingInRadians("CTLDFatCowManager", leader, true) + local left = hdg - math.pi / 2 + ctrl:setTask(landMission({ x = p.x + math.cos(left) * LAND_OFFSET_M, y = p.y, z = p.z + math.sin(left) * LAND_OFFSET_M })) + cow.ai.phase = CTLDFatCowManager.PHASE_LANDING +end + +function CTLDFatCowManager:_returnHome(cow) + self:_cancelDeploy(cow) + if cow.state ~= CTLDFatCow.STATE_STOWED then self:pack(cow.unitName) end + local ctrl = controllerOf(cow.ai.groupName) + if ctrl and cow.ai.home then ctrl:setTask(landMission(cow.ai.home)) end + cow.ai.phase = CTLDFatCowManager.PHASE_RETURNING + if self._aiByFlight then self._aiByFlight[cow.ai.flightGroup] = nil end +end + +function CTLDFatCowManager:_cancelDeploy(cow) + if cow.ai and cow.ai.deployTimer then + ctld.scheduler.remove(cow.ai.deployTimer) + cow.ai.deployTimer = nil + end +end + +--- The flight's group name when `unit` leads it (its first unit), else nil: a wingman landing or +-- taking off first must not move the cow or close the FARP under the others. +local function flightLedBy(unit) + local okG, group = pcall(unit.getGroup, unit) + if not (okG and group) then return nil end + local leader = group:getUnit(1) + if not (leader and leader:getName() == unit:getName()) then return nil end + return group:getName() +end + +--- True when a player flies in the group: its cow then moves only on the F10 commands (Zip, 2026-10-26: +-- a cow that took off whenever the leader hopped onto its FARP). An AI flight cannot use the menu, so +-- its cow (a mission-placed one, fatCowAICows) keeps landing and taking off with the leader. +local function flightHasPlayer(groupName) + local g = Group.getByName(groupName) + if not (g and g:isExist()) then return false end + for _, u in ipairs(g:getUnits() or {}) do + local ok, pn = pcall(function() return u:getPlayerName() end) + if ok and pn then return true end + end + return false +end + +--- The flight's group id, for messages: the cow talks to the flight it serves. +local function flightGroupId(cow) + local g = Group.getByName(cow.ai.flightGroup) + return g and g:isExist() and g:getID() or nil +end + +--- S_EVENT_LAND: the cow lands → it deploys after fatCowDeployDelay, or despawns when it was going home; +-- an AI flight's leader lands → its cow lands next to it (a player flight uses "Land here"). +function CTLDFatCowManager:onLand(event) + local unit = event and event.initiator + local name = ctld.utils.safeObjectName(unit) + if not name then return end + + local cow = self._cows[name] + if cow and cow.ai then + if cow.ai.phase == CTLDFatCowManager.PHASE_RETURNING then + local g = Group.getByName(cow.ai.groupName) + self._cows[name] = nil + self:_releaseFarpName(name) + if g and g:isExist() then g:destroy() end + elseif cow.ai.phase == CTLDFatCowManager.PHASE_LANDING then + cow.ai.phase = CTLDFatCowManager.PHASE_LANDED + local mgr = self + cow.ai.deployTimer = ctld.scheduler.schedule(function() + cow.ai.deployTimer = nil + local u = Unit.getByName(cow.unitName) + if cow.ai.phase == CTLDFatCowManager.PHASE_LANDED and u and u:isExist() then + cow.notifyGroupId = flightGroupId(cow) + mgr:deploy(u) + end + end, nil, timer.getTime() + ctld.gs("fatCowDeployDelay")) + end + return + end + + local flight = flightLedBy(unit) + local escort = flight and self:aiCowOfFlight(flight) + if escort and escort.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING and not flightHasPlayer(flight) then + self:_landNextTo(escort, unit) + end +end + +--- An AI flight's leader takes off → its cow packs and follows again, or goes home if empty (a player +-- flight uses "Take off" / "Join us"). +function CTLDFatCowManager:_onAITakeoff(name) + local unit = Unit.getByName(name) + if not unit then return end + local flight = flightLedBy(unit) + local cow = flight and self:aiCowOfFlight(flight) + if not cow or cow.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING or flightHasPlayer(flight) then return end + self:_cancelDeploy(cow) + if cow.state ~= CTLDFatCow.STATE_STOWED then self:pack(cow.unitName) end + setCargoMass(cow) + if cow:isEmpty() then + self:_returnHome(cow) + else + self:_follow(cow) + end +end + +-- ── F10 orders to the flight's cow ───────────────────────────────────────────────────────────── + +--- Packs the FARP (if open) and stops a pending deployment: the cow is about to move. +function CTLDFatCowManager:_readyToMove(cow) + self:_cancelDeploy(cow) + if cow.state ~= CTLDFatCow.STATE_STOWED then self:pack(cow.unitName) end + setCargoMass(cow) +end + +function CTLDFatCowManager:_orbit(cow, point) + local ctrl = controllerOf(cow.ai.groupName) + if not ctrl then return end + ctrl:setTask({ id = "Orbit", params = { pattern = "Circle", point = { x = point.x, y = point.z }, + altitude = land.getHeight({ x = point.x, y = point.z }) + CRUISE_ALT_M, speed = ORBIT_SPEED_MPS } }) + cow.ai.phase = CTLDFatCowManager.PHASE_HOLDING +end + +--- The cow of the requester's flight, or nil after telling the requester there is none. +function CTLDFatCowManager:_flightCowFor(t) + local cow = self:aiCowOfFlight(t:getGroup():getName()) + local u = cow and Unit.getByName(cow.unitName) + if u and u:isExist() then return cow, u end + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Your flight has no Fat Cow."), 10) + return nil +end + +--- "Land here": the cow lands next to the requester and opens its FARP fatCowDeployDelay later. +function CTLDFatCowManager:orderLandHere(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local cow, u = self:_flightCowFor(t) + if not cow then return end + if not u:inAir() then + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("The Fat Cow is already on the ground."), 10) + return + end + self:_landNextTo(cow, t) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), + ctld.tr("Fat Cow landing next to you: FARP open %1 s after touchdown.", ctld.gs("fatCowDeployDelay")), 10) +end + +--- "Take off": the cow packs its FARP, lifts off and orbits where it stands. +function CTLDFatCowManager:orderTakeOff(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local cow, u = self:_flightCowFor(t) + if not cow then return end + self:_readyToMove(cow) + self:_orbit(cow, u:getPoint()) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow taking off: it orbits where it stands."), 10) +end + +--- "Hold here": the cow packs its FARP if needed and orbits over the requester's position. +function CTLDFatCowManager:orderHoldHere(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local cow = self:_flightCowFor(t) + if not cow then return end + self:_readyToMove(cow) + self:_orbit(cow, t:getPoint()) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow holding over your position."), 10) +end + +--- "Join us": the cow packs its FARP if needed and follows the flight. +function CTLDFatCowManager:orderJoin(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local cow = self:_flightCowFor(t) + if not cow then return end + self:_readyToMove(cow) + self:_follow(cow) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow joining your flight."), 10) +end + +function CTLDFatCowManager:_forgetAICow(name) + for flight, cow in pairs(self._aiByFlight or {}) do + if cow.unitName == name then self._aiByFlight[flight] = nil end + end +end + +--- True when a point is on land: a cow neither appears nor goes home over water (a carrier is a +-- logistic zone too). +local function onLand(p) + if not (land.getSurfaceType and land.SurfaceType) then return true end + local ok, s = pcall(land.getSurfaceType, { x = p.x, y = p.z }) + return not ok or (s ~= land.SurfaceType.WATER and s ~= land.SurfaceType.SHALLOW_WATER) +end + +--- The friendly logistic zone on land nearest to a point, and its distance, or nil. +function CTLDFatCowManager:nearestHomeZone(point, coalitionId) + local best, bestD + for _, zone in ipairs(CTLDZoneManager.getInstance():getLogisticZonesForCoalition(coalitionId)) do + local c = zone:getCenter() + if c and onLand(c) then + local d = ctld.utils.getDistance("CTLDFatCowManager", point, c) + if not bestD or d < bestD then best, bestD = zone, d end + end + end + return best, bestD +end + +local function etaMinutes(distanceM) + return math.max(1, math.floor(distanceM / AI_COW_CRUISE_MPS / 60 + 0.5)) +end + +--- F10 "Request a Fat Cow" > a loadout, from anywhere: it takes off from the friendly logistic zone +-- nearest to the requester, carrying that fatCowLoadouts entry, and joins the flight. One cow per flight. +function CTLDFatCowManager:requestAICow(unitName, loadoutIndex) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = ctld.utils.getGroupId(t) + local flight = t:getGroup():getName() + if self:aiCowOfFlight(flight) then + trigger.action.outTextForGroup(gid, ctld.tr("Your flight already has a Fat Cow."), 10) + return + end + if not loadoutStock(loadoutIndex) then return end + local p = t:getPoint() + local zone, distance = self:nearestHomeZone(p, t:getCoalition()) + if not zone then + trigger.action.outTextForGroup(gid, ctld.tr("No friendly logistic zone to send a Fat Cow from."), 10) + return + end + self._aiCounter = (self._aiCounter or 0) + 1 + local home = zone:getCenter() + local hdg = math.atan2(p.z - home.z, p.x - home.x) -- towards the requester + local alt = land.getHeight({ x = home.x, y = home.z }) + CRUISE_ALT_M + local groupName = string.format("Fat Cow %s #%d", flight, self._aiCounter) + -- An air start over the zone: no parking, no trees, and a carrier zone is never chosen (onLand). + local data = { + name = groupName, + task = "Transport", + route = { points = { { + type = "Turning Point", action = "Turning Point", + x = home.x, y = home.z, alt = alt, alt_type = "BARO", speed = CRUISE_SPEED_MPS, speed_locked = true, + task = { id = "ComboTask", params = { tasks = {} } }, + } } }, + units = { { + name = groupName .. "-1", type = ctld.gs("fatCowAIType"), skill = "High", + x = home.x, y = home.z, alt = alt, alt_type = "BARO", speed = CRUISE_SPEED_MPS, heading = hdg, + payload = { fuel = AI_COW_FUEL_KG, flare = 0, chaff = 0, gun = 100, pylons = {} }, + } }, + } + local ok, g = pcall(coalition.addGroup, t:getCountry(), Group.Category.HELICOPTER, data) + if not (ok and g) then + ctld.utils.log("ERROR", "CTLDFatCowManager: Fat Cow for '%s' not created: %s", flight, tostring(g)) + trigger.action.outTextForGroup(gid, ctld.tr("The Fat Cow could not be brought in."), 10) + return + end + local mgr = self + local zoneName = zone.name + -- The group exists at once, its controller takes a task better on the next frame. + ctld.scheduler.schedule(function() + local cow = mgr:addAICow(groupName, flight, home, loadoutIndex) + if cow then cow.ai.homeName = zoneName end + end, nil, timer.getTime() + 1) + trigger.action.outTextForGroup(gid, + ctld.tr("Fat Cow on its way from %1: %2 km, joining in about %3 min. Land here, Take off, Hold here and Join us command it.", + zoneName, math.floor(distance / 1000 + 0.5), etaMinutes(distance)), 15) +end + +--- F10 "RTB": it packs and goes home. +function CTLDFatCowManager:releaseAICow(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local cow = self:aiCowOfFlight(t:getGroup():getName()) + if not cow then + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Your flight has no Fat Cow."), 10) + return + end + self:_returnHome(cow) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow RTB."), 10) +end + +function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) + if ctld.gs("fatCowAIType") == "" then return end + menu:addSubMenu({ path[1] }, path[2], { order = 50 }) + local mgr = CTLDFatCowManager.getInstance + local u = { unitName = playerObj.unitName } + local requestPath = { path[1], path[2], ctld.tr("Request a Fat Cow") } + menu:addSubMenu(path, requestPath[3], { order = 1 }) + self:_addLoadoutCommands(menu, requestPath, playerObj.unitName, "requestAICow") + menu:addCommand(path, ctld.tr("Land here"), function(arg) mgr():orderLandHere(arg.unitName) end, u, { order = 2 }) + menu:addCommand(path, ctld.tr("Take off"), function(arg) mgr():orderTakeOff(arg.unitName) end, u, { order = 3 }) + menu:addCommand(path, ctld.tr("Hold here"), function(arg) mgr():orderHoldHere(arg.unitName) end, u, { order = 4 }) + menu:addCommand(path, ctld.tr("Join us"), function(arg) mgr():orderJoin(arg.unitName) end, u, { order = 5 }) + menu:addCommand(path, ctld.tr("Fat Cow status"), function(arg) mgr():reportFlightCowStatus(arg.unitName) end, u, { order = 6 }) + menu:addCommand(path, ctld.tr("FARP status"), function(arg) mgr():reportFarpStatus(arg.unitName) end, u, { order = 7 }) + menu:addCommand(path, ctld.tr("RTB"), function(arg) mgr():releaseAICow(arg.unitName) end, u, { order = 8 }) +end + +-- Literal ctld.tr calls, so the dictionary generator sees each phase. +local function phaseText(cow) + if cow.state == CTLDFatCow.STATE_DEPLOYED then return ctld.tr("FARP open") end + local p = cow.ai.phase + if p == CTLDFatCowManager.PHASE_HOLDING then return ctld.tr("holding") end + if p == CTLDFatCowManager.PHASE_FOLLOWING then return ctld.tr("following your flight") end + if p == CTLDFatCowManager.PHASE_LANDING then return ctld.tr("landing") end + if p == CTLDFatCowManager.PHASE_LANDED then return ctld.tr("on the ground") end + if p == CTLDFatCowManager.PHASE_RETURNING then return ctld.tr("going home") end + return tostring(p) +end + +--- F10 "Fat Cow status": where the flight's cow is, when it joins, and what it holds. +function CTLDFatCowManager:reportFlightCowStatus(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = ctld.utils.getGroupId(t) + local cow = self:aiCowOfFlight(t:getGroup():getName()) + local u = cow and Unit.getByName(cow.unitName) + if not (u and u:isExist()) then + trigger.action.outTextForGroup(gid, ctld.tr("Your flight has no Fat Cow."), 10) + return + end + local me, it = t:getPoint(), u:getPoint() + local d = ctld.utils.getDistance("CTLDFatCowManager", me, it) + local bearing = math.floor((math.deg(math.atan2(it.z - me.z, it.x - me.x)) + 360) % 360 + 0.5) + local lines = { ctld.tr("Fat Cow: %1, %2 km at %3°.", phaseText(cow), string.format("%.1f", d / 1000), bearing) } + if cow.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING and d > 1500 then + lines[#lines + 1] = ctld.tr("Joining in about %1 min.", etaMinutes(d)) + end + lines[#lines + 1] = ctld.tr("Stock: %1", CTLDFatCow.describeStock(self:liveStock(cow))) + trigger.action.outTextForGroup(gid, table.concat(lines, "\n"), 15) +end + +--- The cow's stock as it stands: read from its FARP when the FARP is open. +function CTLDFatCowManager:liveStock(cow) + if cow.state == CTLDFatCow.STATE_DEPLOYED then + local w = warehouseOf(cow.farpName) + if w then cow:readWarehouse(w) end + end + return cow.stock +end + +local FARP_STATUS_RANGE_M = 2000 + +--- F10 "FARP status": what the Fat Cow FARP the player is at still holds, whoever it belongs to. +function CTLDFatCowManager:reportFarpStatus(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local me = t:getPoint() + local best, bestD + for _, cow in pairs(self._cows) do + local farp = cow.state == CTLDFatCow.STATE_DEPLOYED and cow.farpName and StaticObject.getByName(cow.farpName) + if farp and farp:isExist() then + local d = ctld.utils.getDistance("CTLDFatCowManager", me, farp:getPoint()) + if d <= FARP_STATUS_RANGE_M and (not bestD or d < bestD) then best, bestD = cow, d end + end + end + local gid = ctld.utils.getGroupId(t) + if not best then + trigger.action.outTextForGroup(gid, ctld.tr("No Fat Cow FARP within %1 km.", FARP_STATUS_RANGE_M / 1000), 10) + return + end + trigger.action.outTextForGroup(gid, + ctld.tr("Fat Cow FARP %1 m away: %2", math.floor(bestD + 0.5), CTLDFatCow.describeStock(self:liveStock(best))), 15) +end + +-- ── Mission-placed cows (fatCowAICows): the VMCT contract ────────────────────────────────────── +-- Each entry { group = "", escort = "", loadout = }. CTLD activates +-- the cow when the escorted flight is in the world (at start, or when its first unit is born). + +--- True when the group has a unit actually in the world: a late-activated group exists to scripts +-- before it is activated (known limitation late-activated-group-is-visible). +local function inWorld(group) + if not (group and group:isExist()) then return false end + local u = group:getUnit(1) + if not u then return false end + local ok, active = pcall(function() return u:isActive() end) + return not ok or active ~= false +end + +function CTLDFatCowManager:_activateMissionCow(entry) + local g = Group.getByName(entry.group) + local flight = Group.getByName(entry.escort) + if not (g and inWorld(flight)) then return false end + if self:aiCowOfFlight(entry.escort) then return true end + if not loadoutStock(entry.loadout or 1) then + ctld.utils.log("ERROR", "CTLDFatCowManager: fatCowAICows entry '%s' names loadout %s, which fatCowLoadouts does not have: not activated", + tostring(entry.group), tostring(entry.loadout or 1)) + return true + end + pcall(g.activate, g) + local u = g:getUnit(1) + local home = u and u:getPoint() or nil + local mgr = self + ctld.scheduler.schedule(function() mgr:addAICow(entry.group, entry.escort, home, entry.loadout or 1) end, + nil, timer.getTime() + 1) + return true +end + +function CTLDFatCowManager:_activateMissionCows() + for _, entry in ipairs(ctld.gs("fatCowAICows") or {}) do + if entry.group and entry.escort and not (self._missionCowsDone and self._missionCowsDone[entry.group]) then + if self:_activateMissionCow(entry) then + self._missionCowsDone = self._missionCowsDone or {} + self._missionCowsDone[entry.group] = true + end + end + end +end diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index a746843b..9c704a19 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.28" +ctld.i18n["en"].translation_version = "1.34" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -612,3 +612,99 @@ ctld.i18n["en"]["lasing"] = "lasing" ctld.i18n["en"]["standing by on"] = "standing by on" ctld.i18n["en"]["unknown"] = "unknown" ctld.i18n["en"]["UNKNOWN"] = "UNKNOWN" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["en"]["%1 is next to you: load it into your hold."] = "%1 is next to you: load it into your hold." +ctld.i18n["en"]["%1 kg of fuel"] = "%1 kg of fuel" +ctld.i18n["en"]["Deploy the Fat Cow FARP"] = "Deploy the Fat Cow FARP" +ctld.i18n["en"]["Deploying the Fat Cow FARP..."] = "Deploying the Fat Cow FARP..." +ctld.i18n["en"]["Fat Cow"] = "Fat Cow" +ctld.i18n["en"]["Fat Cow FARP open: %1"] = "Fat Cow FARP open: %1" +ctld.i18n["en"]["Fat Cow FARP packed: %1 left."] = "Fat Cow FARP packed: %1 left." +ctld.i18n["en"]["Fat Cow fuel tank (800 gal)"] = "Fat Cow fuel tank (800 gal)" +ctld.i18n["en"]["Fat Cow stock"] = "Fat Cow stock" +ctld.i18n["en"]["Fat Cow stock: %1"] = "Fat Cow stock: %1" +ctld.i18n["en"]["Land to deploy the Fat Cow FARP."] = "Land to deploy the Fat Cow FARP." +ctld.i18n["en"]["No Fat Cow crate on board: load a fuel tank or an ammo crate first."] = "No Fat Cow crate on board: load a fuel tank or an ammo crate first." +ctld.i18n["en"]["No Fat Cow FARP deployed."] = "No Fat Cow FARP deployed." +ctld.i18n["en"]["Pack the Fat Cow FARP"] = "Pack the Fat Cow FARP" +ctld.i18n["en"]["Request a Fat Cow"] = "Request a Fat Cow" +ctld.i18n["en"]["The Fat Cow could not be brought in."] = "The Fat Cow could not be brought in." +ctld.i18n["en"]["The Fat Cow crates on board are empty."] = "The Fat Cow crates on board are empty." +ctld.i18n["en"]["The Fat Cow FARP could not be deployed."] = "The Fat Cow FARP could not be deployed." +ctld.i18n["en"]["The Fat Cow FARP is already deployed."] = "The Fat Cow FARP is already deployed." +ctld.i18n["en"]["Your flight already has a Fat Cow."] = "Your flight already has a Fat Cow." +ctld.i18n["en"]["Your flight has no Fat Cow."] = "Your flight has no Fat Cow." + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["en"]["%1 Fat Cow crate(s) unloaded."] = "%1 Fat Cow crate(s) unloaded." +ctld.i18n["en"]["FARP status"] = "FARP status" +ctld.i18n["en"]["Fat Cow FARP %1 m away: %2"] = "Fat Cow FARP %1 m away: %2" +ctld.i18n["en"]["Fat Cow holding over your position."] = "Fat Cow holding over your position." +ctld.i18n["en"]["Fat Cow joining your flight."] = "Fat Cow joining your flight." +ctld.i18n["en"]["Fat Cow landing next to you: FARP open %1 s after touchdown."] = "Fat Cow landing next to you: FARP open %1 s after touchdown." +ctld.i18n["en"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Land here, Take off, Hold here and Join us command it."] = "Fat Cow on its way from %1: %2 km, joining in about %3 min. Land here, Take off, Hold here and Join us command it." +ctld.i18n["en"]["Fat Cow status"] = "Fat Cow status" +ctld.i18n["en"]["Fat Cow taking off: it orbits where it stands."] = "Fat Cow taking off: it orbits where it stands." +ctld.i18n["en"]["Fat Cow: %1, %2 km at %3°."] = "Fat Cow: %1, %2 km at %3°." +ctld.i18n["en"]["Hold here"] = "Hold here" +ctld.i18n["en"]["Join us"] = "Join us" +ctld.i18n["en"]["Joining in about %1 min."] = "Joining in about %1 min." +ctld.i18n["en"]["Land here"] = "Land here" +ctld.i18n["en"]["Loaded into your hold: %1"] = "Loaded into your hold: %1" +ctld.i18n["en"]["No Fat Cow FARP within %1 km."] = "No Fat Cow FARP within %1 km." +ctld.i18n["en"]["No friendly logistic zone to send a Fat Cow from."] = "No friendly logistic zone to send a Fat Cow from." +ctld.i18n["en"]["Not enough room: %1 crate(s) on board, %2 more needed, %3 at most."] = "Not enough room: %1 crate(s) on board, %2 more needed, %3 at most." +ctld.i18n["en"]["Stock: %1"] = "Stock: %1" +ctld.i18n["en"]["Take off"] = "Take off" +ctld.i18n["en"]["The Fat Cow is already on the ground."] = "The Fat Cow is already on the ground." +ctld.i18n["en"]["This loadout cannot be loaded."] = "This loadout cannot be loaded." +ctld.i18n["en"]["Unload the Fat Cow crates"] = "Unload the Fat Cow crates" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["en"]["FARP open"] = "FARP open" +ctld.i18n["en"]["following your flight"] = "following your flight" +ctld.i18n["en"]["going home"] = "going home" +ctld.i18n["en"]["holding"] = "holding" +ctld.i18n["en"]["landing"] = "landing" +ctld.i18n["en"]["on the ground"] = "on the ground" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["en"]["Fat Cow RTB."] = "Fat Cow RTB." +ctld.i18n["en"]["RTB"] = "RTB" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["en"]["30 mm rounds"] = "30 mm rounds" +ctld.i18n["en"]["40 Hellfires (25 laser, 15 radar), 2400 rounds"] = "40 Hellfires (25 laser, 15 radar), 2400 rounds" +ctld.i18n["en"]["40 laser Hellfires, 2400 rounds"] = "40 laser Hellfires, 2400 rounds" +ctld.i18n["en"]["60 Hellfires (38 laser, 22 radar), 1200 rounds"] = "60 Hellfires (38 laser, 22 radar), 1200 rounds" +ctld.i18n["en"]["60 laser Hellfires, 1200 rounds"] = "60 laser Hellfires, 1200 rounds" +ctld.i18n["en"]["Fat Cow 30 mm (x1200)"] = "Fat Cow 30 mm (x1200)" +ctld.i18n["en"]["Fat Cow laser Hellfires (x20)"] = "Fat Cow laser Hellfires (x20)" +ctld.i18n["en"]["Fat Cow radar Hellfires (x20)"] = "Fat Cow radar Hellfires (x20)" +ctld.i18n["en"]["laser Hellfire"] = "laser Hellfire" +ctld.i18n["en"]["Load"] = "Load" +ctld.i18n["en"]["radar Hellfire"] = "radar Hellfire" +ctld.i18n["en"]["Request a crate"] = "Request a crate" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["en"]["20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds"] = "20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds" +ctld.i18n["en"]["20 laser Hellfires, 16 Ataka, 1200 + 500 rounds"] = "20 laser Hellfires, 16 Ataka, 1200 + 500 rounds" +ctld.i18n["en"]["2A42 AP rounds"] = "2A42 AP rounds" +ctld.i18n["en"]["2A42 HE rounds"] = "2A42 HE rounds" +ctld.i18n["en"]["32 Ataka, 500 rounds (Mi-24P)"] = "32 Ataka, 500 rounds (Mi-24P)" +ctld.i18n["en"]["32 Shturm, 500 rounds (Mi-24P)"] = "32 Shturm, 500 rounds (Mi-24P)" +ctld.i18n["en"]["40 Vikhr, 920 rounds (Ka-50)"] = "40 Vikhr, 920 rounds (Ka-50)" +ctld.i18n["en"]["60 Vikhr, 460 rounds (Ka-50)"] = "60 Vikhr, 460 rounds (Ka-50)" +ctld.i18n["en"]["Ataka"] = "Ataka" +ctld.i18n["en"]["Fat Cow 2A42 AP (x460)"] = "Fat Cow 2A42 AP (x460)" +ctld.i18n["en"]["Fat Cow 2A42 HE (x460)"] = "Fat Cow 2A42 HE (x460)" +ctld.i18n["en"]["Fat Cow Ataka (x16)"] = "Fat Cow Ataka (x16)" +ctld.i18n["en"]["Fat Cow GSh-30 (x500)"] = "Fat Cow GSh-30 (x500)" +ctld.i18n["en"]["Fat Cow Shturm (x16)"] = "Fat Cow Shturm (x16)" +ctld.i18n["en"]["Fat Cow Vikhr (x20)"] = "Fat Cow Vikhr (x20)" +ctld.i18n["en"]["GSh-30 rounds"] = "GSh-30 rounds" +ctld.i18n["en"]["Mixed"] = "Mixed" +ctld.i18n["en"]["Russian"] = "Russian" +ctld.i18n["en"]["Shturm"] = "Shturm" +ctld.i18n["en"]["Vikhr"] = "Vikhr" diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 1a9ce457..856920d9 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.28" +ctld.i18n["es"].translation_version = "1.34" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -619,3 +619,99 @@ ctld.i18n["es"]["lasing"] = "designando" ctld.i18n["es"]["standing by on"] = "en espera en" ctld.i18n["es"]["unknown"] = "desconocido" ctld.i18n["es"]["UNKNOWN"] = "DESCONOCIDO" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["es"]["%1 is next to you: load it into your hold."] = "%1 está a tu lado: cárgala en la bodega." +ctld.i18n["es"]["%1 kg of fuel"] = "%1 kg de combustible" +ctld.i18n["es"]["Deploy the Fat Cow FARP"] = "Desplegar el FARP Fat Cow" +ctld.i18n["es"]["Deploying the Fat Cow FARP..."] = "Desplegando el FARP Fat Cow..." +ctld.i18n["es"]["Fat Cow"] = "Fat Cow" +ctld.i18n["es"]["Fat Cow FARP open: %1"] = "FARP Fat Cow abierto: %1" +ctld.i18n["es"]["Fat Cow FARP packed: %1 left."] = "FARP Fat Cow recogido: quedan %1." +ctld.i18n["es"]["Fat Cow fuel tank (800 gal)"] = "Tanque Fat Cow (800 gal)" +ctld.i18n["es"]["Fat Cow stock"] = "Existencias del Fat Cow" +ctld.i18n["es"]["Fat Cow stock: %1"] = "Existencias del Fat Cow: %1" +ctld.i18n["es"]["Land to deploy the Fat Cow FARP."] = "Aterriza para desplegar el FARP Fat Cow." +ctld.i18n["es"]["No Fat Cow crate on board: load a fuel tank or an ammo crate first."] = "Ninguna caja Fat Cow a bordo: carga primero un tanque o una caja de munición." +ctld.i18n["es"]["No Fat Cow FARP deployed."] = "Ningún FARP Fat Cow desplegado." +ctld.i18n["es"]["Pack the Fat Cow FARP"] = "Recoger el FARP Fat Cow" +ctld.i18n["es"]["Request a Fat Cow"] = "Solicitar un Fat Cow" +ctld.i18n["es"]["The Fat Cow could not be brought in."] = "No se pudo traer el Fat Cow." +ctld.i18n["es"]["The Fat Cow crates on board are empty."] = "Las cajas Fat Cow a bordo están vacías." +ctld.i18n["es"]["The Fat Cow FARP could not be deployed."] = "No se pudo desplegar el FARP Fat Cow." +ctld.i18n["es"]["The Fat Cow FARP is already deployed."] = "El FARP Fat Cow ya está desplegado." +ctld.i18n["es"]["Your flight already has a Fat Cow."] = "Tu escuadrilla ya tiene un Fat Cow." +ctld.i18n["es"]["Your flight has no Fat Cow."] = "Tu escuadrilla no tiene Fat Cow." + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["es"]["%1 Fat Cow crate(s) unloaded."] = "%1 caja(s) Fat Cow descargada(s)." +ctld.i18n["es"]["FARP status"] = "Estado del FARP" +ctld.i18n["es"]["Fat Cow FARP %1 m away: %2"] = "FARP Fat Cow a %1 m: %2" +ctld.i18n["es"]["Fat Cow holding over your position."] = "Fat Cow en espera sobre tu posición." +ctld.i18n["es"]["Fat Cow joining your flight."] = "Fat Cow se une a tu escuadrilla." +ctld.i18n["es"]["Fat Cow landing next to you: FARP open %1 s after touchdown."] = "Fat Cow aterriza a tu lado: FARP abierto %1 s tras tocar tierra." +ctld.i18n["es"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Land here, Take off, Hold here and Join us command it."] = "Fat Cow en camino desde %1: %2 km, se une en unos %3 min. Aterriza aquí, Despega, Espera aquí y Únete la controlan." +ctld.i18n["es"]["Fat Cow status"] = "Estado del Fat Cow" +ctld.i18n["es"]["Fat Cow taking off: it orbits where it stands."] = "Fat Cow despega: orbita sobre su posición." +ctld.i18n["es"]["Fat Cow: %1, %2 km at %3°."] = "Fat Cow: %1, %2 km al %3°." +ctld.i18n["es"]["Hold here"] = "Espera aquí en órbita" +ctld.i18n["es"]["Join us"] = "Únete a nosotros" +ctld.i18n["es"]["Joining in about %1 min."] = "Se une en unos %1 min." +ctld.i18n["es"]["Land here"] = "Aterriza aquí" +ctld.i18n["es"]["Loaded into your hold: %1"] = "Cargado en la bodega: %1" +ctld.i18n["es"]["No Fat Cow FARP within %1 km."] = "Ningún FARP Fat Cow a menos de %1 km." +ctld.i18n["es"]["No friendly logistic zone to send a Fat Cow from."] = "Ninguna zona logística aliada desde donde enviar un Fat Cow." +ctld.i18n["es"]["Not enough room: %1 crate(s) on board, %2 more needed, %3 at most."] = "No hay sitio: %1 caja(s) a bordo, %2 más necesarias, %3 como máximo." +ctld.i18n["es"]["Stock: %1"] = "Existencias: %1" +ctld.i18n["es"]["Take off"] = "Despega" +ctld.i18n["es"]["The Fat Cow is already on the ground."] = "El Fat Cow ya está en tierra." +ctld.i18n["es"]["This loadout cannot be loaded."] = "Esta carga no se puede cargar." +ctld.i18n["es"]["Unload the Fat Cow crates"] = "Descargar las cajas Fat Cow" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["es"]["FARP open"] = "FARP abierto" +ctld.i18n["es"]["following your flight"] = "sigue a tu escuadrilla" +ctld.i18n["es"]["going home"] = "regresa" +ctld.i18n["es"]["holding"] = "en espera" +ctld.i18n["es"]["landing"] = "aterrizando" +ctld.i18n["es"]["on the ground"] = "en tierra" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["es"]["Fat Cow RTB."] = "Fat Cow regresa a la base." +ctld.i18n["es"]["RTB"] = "Regresa a la base" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["es"]["30 mm rounds"] = "proyectiles de 30 mm" +ctld.i18n["es"]["40 Hellfires (25 laser, 15 radar), 2400 rounds"] = "40 Hellfire (25 láser, 15 radar), 2400 proyectiles" +ctld.i18n["es"]["40 laser Hellfires, 2400 rounds"] = "40 Hellfire láser, 2400 proyectiles" +ctld.i18n["es"]["60 Hellfires (38 laser, 22 radar), 1200 rounds"] = "60 Hellfire (38 láser, 22 radar), 1200 proyectiles" +ctld.i18n["es"]["60 laser Hellfires, 1200 rounds"] = "60 Hellfire láser, 1200 proyectiles" +ctld.i18n["es"]["Fat Cow 30 mm (x1200)"] = "Fat Cow 30 mm (x1200)" +ctld.i18n["es"]["Fat Cow laser Hellfires (x20)"] = "Hellfire láser Fat Cow (x20)" +ctld.i18n["es"]["Fat Cow radar Hellfires (x20)"] = "Hellfire radar Fat Cow (x20)" +ctld.i18n["es"]["laser Hellfire"] = "Hellfire láser" +ctld.i18n["es"]["Load"] = "Cargar" +ctld.i18n["es"]["radar Hellfire"] = "Hellfire radar" +ctld.i18n["es"]["Request a crate"] = "Solicitar una caja" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["es"]["20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds"] = "20 Hellfire (12 láser, 8 radar), 20 Vikhr, 1200 + 460 proyectiles" +ctld.i18n["es"]["20 laser Hellfires, 16 Ataka, 1200 + 500 rounds"] = "20 Hellfire láser, 16 Ataka, 1200 + 500 proyectiles" +ctld.i18n["es"]["2A42 AP rounds"] = "proyectiles perforantes de 2A42" +ctld.i18n["es"]["2A42 HE rounds"] = "proyectiles explosivos de 2A42" +ctld.i18n["es"]["32 Ataka, 500 rounds (Mi-24P)"] = "32 Ataka, 500 proyectiles (Mi-24P)" +ctld.i18n["es"]["32 Shturm, 500 rounds (Mi-24P)"] = "32 Shturm, 500 proyectiles (Mi-24P)" +ctld.i18n["es"]["40 Vikhr, 920 rounds (Ka-50)"] = "40 Vikhr, 920 proyectiles (Ka-50)" +ctld.i18n["es"]["60 Vikhr, 460 rounds (Ka-50)"] = "60 Vikhr, 460 proyectiles (Ka-50)" +ctld.i18n["es"]["Ataka"] = "Ataka" +ctld.i18n["es"]["Fat Cow 2A42 AP (x460)"] = "2A42 perforantes Fat Cow (x460)" +ctld.i18n["es"]["Fat Cow 2A42 HE (x460)"] = "2A42 explosivos Fat Cow (x460)" +ctld.i18n["es"]["Fat Cow Ataka (x16)"] = "Ataka Fat Cow (x16)" +ctld.i18n["es"]["Fat Cow GSh-30 (x500)"] = "GSh-30 Fat Cow (x500)" +ctld.i18n["es"]["Fat Cow Shturm (x16)"] = "Shturm Fat Cow (x16)" +ctld.i18n["es"]["Fat Cow Vikhr (x20)"] = "Vikhr Fat Cow (x20)" +ctld.i18n["es"]["GSh-30 rounds"] = "proyectiles de GSh-30" +ctld.i18n["es"]["Mixed"] = "Mixtos" +ctld.i18n["es"]["Russian"] = "Rusos" +ctld.i18n["es"]["Shturm"] = "Shturm" +ctld.i18n["es"]["Vikhr"] = "Vikhr" diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index bb8aaf6f..196f1fb9 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.28" +ctld.i18n["fr"].translation_version = "1.34" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -644,3 +644,99 @@ ctld.i18n["fr"]["lasing"] = "désigne" ctld.i18n["fr"]["standing by on"] = "en attente sur" ctld.i18n["fr"]["unknown"] = "inconnu" ctld.i18n["fr"]["UNKNOWN"] = "INCONNU" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["fr"]["%1 is next to you: load it into your hold."] = "%1 est à côté de vous : chargez-la en soute." +ctld.i18n["fr"]["%1 kg of fuel"] = "%1 kg de carburant" +ctld.i18n["fr"]["Deploy the Fat Cow FARP"] = "Déployer la FARP Fat Cow" +ctld.i18n["fr"]["Deploying the Fat Cow FARP..."] = "Déploiement de la FARP Fat Cow..." +ctld.i18n["fr"]["Fat Cow"] = "Fat Cow" +ctld.i18n["fr"]["Fat Cow FARP open: %1"] = "FARP Fat Cow ouverte : %1" +ctld.i18n["fr"]["Fat Cow FARP packed: %1 left."] = "FARP Fat Cow repliée : il reste %1." +ctld.i18n["fr"]["Fat Cow fuel tank (800 gal)"] = "Réservoir Fat Cow (800 gal)" +ctld.i18n["fr"]["Fat Cow stock"] = "Stock de la Fat Cow" +ctld.i18n["fr"]["Fat Cow stock: %1"] = "Stock de la Fat Cow : %1" +ctld.i18n["fr"]["Land to deploy the Fat Cow FARP."] = "Posez-vous pour déployer la FARP Fat Cow." +ctld.i18n["fr"]["No Fat Cow crate on board: load a fuel tank or an ammo crate first."] = "Aucune caisse Fat Cow à bord : chargez d'abord un réservoir ou une caisse de munitions." +ctld.i18n["fr"]["No Fat Cow FARP deployed."] = "Aucune FARP Fat Cow déployée." +ctld.i18n["fr"]["Pack the Fat Cow FARP"] = "Replier la FARP Fat Cow" +ctld.i18n["fr"]["Request a Fat Cow"] = "Demander une Fat Cow" +ctld.i18n["fr"]["The Fat Cow could not be brought in."] = "La Fat Cow n'a pas pu être amenée." +ctld.i18n["fr"]["The Fat Cow crates on board are empty."] = "Les caisses Fat Cow à bord sont vides." +ctld.i18n["fr"]["The Fat Cow FARP could not be deployed."] = "La FARP Fat Cow n'a pas pu être déployée." +ctld.i18n["fr"]["The Fat Cow FARP is already deployed."] = "La FARP Fat Cow est déjà déployée." +ctld.i18n["fr"]["Your flight already has a Fat Cow."] = "Votre patrouille a déjà une Fat Cow." +ctld.i18n["fr"]["Your flight has no Fat Cow."] = "Votre patrouille n'a pas de Fat Cow." + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["fr"]["%1 Fat Cow crate(s) unloaded."] = "%1 caisse(s) Fat Cow déchargée(s)." +ctld.i18n["fr"]["FARP status"] = "Statut FARP" +ctld.i18n["fr"]["Fat Cow FARP %1 m away: %2"] = "FARP Fat Cow à %1 m : %2" +ctld.i18n["fr"]["Fat Cow holding over your position."] = "Fat Cow en orbite au-dessus de ta position." +ctld.i18n["fr"]["Fat Cow joining your flight."] = "Fat Cow rejoint ta patrouille." +ctld.i18n["fr"]["Fat Cow landing next to you: FARP open %1 s after touchdown."] = "Fat Cow se pose à côté de toi : FARP ouverte %1 s après le poser." +ctld.i18n["fr"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Land here, Take off, Hold here and Join us command it."] = "Fat Cow en route depuis %1 : %2 km, rejointe dans environ %3 min. Atterris ici, Redécolle, Reste ici et Rejoins-nous la commandent." +ctld.i18n["fr"]["Fat Cow status"] = "Statut Fat Cow" +ctld.i18n["fr"]["Fat Cow taking off: it orbits where it stands."] = "Fat Cow décolle : elle tourne sur place." +ctld.i18n["fr"]["Fat Cow: %1, %2 km at %3°."] = "Fat Cow : %1, %2 km au %3°." +ctld.i18n["fr"]["Hold here"] = "Reste ici en orbite" +ctld.i18n["fr"]["Join us"] = "Rejoins-nous" +ctld.i18n["fr"]["Joining in about %1 min."] = "Rejointe dans environ %1 min." +ctld.i18n["fr"]["Land here"] = "Atterris ici" +ctld.i18n["fr"]["Loaded into your hold: %1"] = "Chargé en soute : %1" +ctld.i18n["fr"]["No Fat Cow FARP within %1 km."] = "Aucune FARP Fat Cow à moins de %1 km." +ctld.i18n["fr"]["No friendly logistic zone to send a Fat Cow from."] = "Aucune zone logistique amie d'où envoyer une Fat Cow." +ctld.i18n["fr"]["Not enough room: %1 crate(s) on board, %2 more needed, %3 at most."] = "Pas assez de place : %1 caisse(s) à bord, %2 de plus demandées, %3 au maximum." +ctld.i18n["fr"]["Stock: %1"] = "Stock : %1" +ctld.i18n["fr"]["Take off"] = "Redécolle" +ctld.i18n["fr"]["The Fat Cow is already on the ground."] = "La Fat Cow est déjà au sol." +ctld.i18n["fr"]["This loadout cannot be loaded."] = "Ce lot ne peut pas être chargé." +ctld.i18n["fr"]["Unload the Fat Cow crates"] = "Décharger les caisses Fat Cow" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["fr"]["FARP open"] = "FARP ouverte" +ctld.i18n["fr"]["following your flight"] = "suit ta patrouille" +ctld.i18n["fr"]["going home"] = "rentre" +ctld.i18n["fr"]["holding"] = "en orbite" +ctld.i18n["fr"]["landing"] = "se pose" +ctld.i18n["fr"]["on the ground"] = "au sol" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["fr"]["Fat Cow RTB."] = "Fat Cow rentre à la base." +ctld.i18n["fr"]["RTB"] = "Rentre à la base" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["fr"]["30 mm rounds"] = "obus de 30 mm" +ctld.i18n["fr"]["40 Hellfires (25 laser, 15 radar), 2400 rounds"] = "40 Hellfire (25 laser, 15 radar), 2400 obus" +ctld.i18n["fr"]["40 laser Hellfires, 2400 rounds"] = "40 Hellfire laser, 2400 obus" +ctld.i18n["fr"]["60 Hellfires (38 laser, 22 radar), 1200 rounds"] = "60 Hellfire (38 laser, 22 radar), 1200 obus" +ctld.i18n["fr"]["60 laser Hellfires, 1200 rounds"] = "60 Hellfire laser, 1200 obus" +ctld.i18n["fr"]["Fat Cow 30 mm (x1200)"] = "Fat Cow 30 mm (x1200)" +ctld.i18n["fr"]["Fat Cow laser Hellfires (x20)"] = "Hellfire laser Fat Cow (x20)" +ctld.i18n["fr"]["Fat Cow radar Hellfires (x20)"] = "Hellfire radar Fat Cow (x20)" +ctld.i18n["fr"]["laser Hellfire"] = "Hellfire laser" +ctld.i18n["fr"]["Load"] = "Charger" +ctld.i18n["fr"]["radar Hellfire"] = "Hellfire radar" +ctld.i18n["fr"]["Request a crate"] = "Demander une caisse" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["fr"]["20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds"] = "20 Hellfire (12 laser, 8 radar), 20 Vikhr, 1200 + 460 obus" +ctld.i18n["fr"]["20 laser Hellfires, 16 Ataka, 1200 + 500 rounds"] = "20 Hellfire laser, 16 Ataka, 1200 + 500 obus" +ctld.i18n["fr"]["2A42 AP rounds"] = "obus perforants de 2A42" +ctld.i18n["fr"]["2A42 HE rounds"] = "obus explosifs de 2A42" +ctld.i18n["fr"]["32 Ataka, 500 rounds (Mi-24P)"] = "32 Ataka, 500 obus (Mi-24P)" +ctld.i18n["fr"]["32 Shturm, 500 rounds (Mi-24P)"] = "32 Shturm, 500 obus (Mi-24P)" +ctld.i18n["fr"]["40 Vikhr, 920 rounds (Ka-50)"] = "40 Vikhr, 920 obus (Ka-50)" +ctld.i18n["fr"]["60 Vikhr, 460 rounds (Ka-50)"] = "60 Vikhr, 460 obus (Ka-50)" +ctld.i18n["fr"]["Ataka"] = "Ataka" +ctld.i18n["fr"]["Fat Cow 2A42 AP (x460)"] = "2A42 perforants Fat Cow (x460)" +ctld.i18n["fr"]["Fat Cow 2A42 HE (x460)"] = "2A42 explosifs Fat Cow (x460)" +ctld.i18n["fr"]["Fat Cow Ataka (x16)"] = "Ataka Fat Cow (x16)" +ctld.i18n["fr"]["Fat Cow GSh-30 (x500)"] = "GSh-30 Fat Cow (x500)" +ctld.i18n["fr"]["Fat Cow Shturm (x16)"] = "Shturm Fat Cow (x16)" +ctld.i18n["fr"]["Fat Cow Vikhr (x20)"] = "Vikhr Fat Cow (x20)" +ctld.i18n["fr"]["GSh-30 rounds"] = "obus de GSh-30" +ctld.i18n["fr"]["Mixed"] = "Mixtes" +ctld.i18n["fr"]["Russian"] = "Russes" +ctld.i18n["fr"]["Shturm"] = "Shturm" +ctld.i18n["fr"]["Vikhr"] = "Vikhr" diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 2b7cfb6c..b5000b14 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.28" +ctld.i18n["ko"].translation_version = "1.34" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -501,3 +501,99 @@ ctld.i18n["ko"]["lasing"] = "레이저 지시:" ctld.i18n["ko"]["standing by on"] = "대기:" ctld.i18n["ko"]["unknown"] = "알 수 없음" ctld.i18n["ko"]["UNKNOWN"] = "알 수 없음" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["ko"]["%1 is next to you: load it into your hold."] = "%1이(가) 옆에 있습니다: 화물칸에 적재하세요." +ctld.i18n["ko"]["%1 kg of fuel"] = "연료 %1 kg" +ctld.i18n["ko"]["Deploy the Fat Cow FARP"] = "Fat Cow FARP 전개" +ctld.i18n["ko"]["Deploying the Fat Cow FARP..."] = "Fat Cow FARP 전개 중..." +ctld.i18n["ko"]["Fat Cow"] = "Fat Cow" +ctld.i18n["ko"]["Fat Cow FARP open: %1"] = "Fat Cow FARP 개방: %1" +ctld.i18n["ko"]["Fat Cow FARP packed: %1 left."] = "Fat Cow FARP 철수: %1 남음." +ctld.i18n["ko"]["Fat Cow fuel tank (800 gal)"] = "Fat Cow 연료 탱크 (800 gal)" +ctld.i18n["ko"]["Fat Cow stock"] = "Fat Cow 재고" +ctld.i18n["ko"]["Fat Cow stock: %1"] = "Fat Cow 재고: %1" +ctld.i18n["ko"]["Land to deploy the Fat Cow FARP."] = "Fat Cow FARP를 전개하려면 착륙하세요." +ctld.i18n["ko"]["No Fat Cow crate on board: load a fuel tank or an ammo crate first."] = "탑재된 Fat Cow 화물 없음: 먼저 연료 탱크나 탄약 상자를 적재하세요." +ctld.i18n["ko"]["No Fat Cow FARP deployed."] = "전개된 Fat Cow FARP 없음." +ctld.i18n["ko"]["Pack the Fat Cow FARP"] = "Fat Cow FARP 철수" +ctld.i18n["ko"]["Request a Fat Cow"] = "Fat Cow 요청" +ctld.i18n["ko"]["The Fat Cow could not be brought in."] = "Fat Cow를 불러올 수 없습니다." +ctld.i18n["ko"]["The Fat Cow crates on board are empty."] = "탑재된 Fat Cow 화물이 비어 있습니다." +ctld.i18n["ko"]["The Fat Cow FARP could not be deployed."] = "Fat Cow FARP를 전개할 수 없습니다." +ctld.i18n["ko"]["The Fat Cow FARP is already deployed."] = "Fat Cow FARP가 이미 전개되어 있습니다." +ctld.i18n["ko"]["Your flight already has a Fat Cow."] = "편대에 이미 Fat Cow가 있습니다." +ctld.i18n["ko"]["Your flight has no Fat Cow."] = "편대에 Fat Cow가 없습니다." + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["ko"]["%1 Fat Cow crate(s) unloaded."] = "Fat Cow 화물 %1개 하역." +ctld.i18n["ko"]["FARP status"] = "FARP 상태" +ctld.i18n["ko"]["Fat Cow FARP %1 m away: %2"] = "%1 m 거리의 Fat Cow FARP: %2" +ctld.i18n["ko"]["Fat Cow holding over your position."] = "Fat Cow가 당신 위치 상공에서 대기합니다." +ctld.i18n["ko"]["Fat Cow joining your flight."] = "Fat Cow가 편대에 합류합니다." +ctld.i18n["ko"]["Fat Cow landing next to you: FARP open %1 s after touchdown."] = "Fat Cow가 옆에 착륙합니다: 착륙 %1초 후 FARP 개방." +ctld.i18n["ko"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Land here, Take off, Hold here and Join us command it."] = "Fat Cow가 %1에서 출발: %2 km, 약 %3분 후 합류. 여기 착륙, 이륙, 여기서 대기, 합류 명령으로 지휘합니다." +ctld.i18n["ko"]["Fat Cow status"] = "Fat Cow 상태" +ctld.i18n["ko"]["Fat Cow taking off: it orbits where it stands."] = "Fat Cow 이륙: 현재 위치에서 선회합니다." +ctld.i18n["ko"]["Fat Cow: %1, %2 km at %3°."] = "Fat Cow: %1, %3° 방향 %2 km." +ctld.i18n["ko"]["Hold here"] = "여기서 선회 대기" +ctld.i18n["ko"]["Join us"] = "합류하라" +ctld.i18n["ko"]["Joining in about %1 min."] = "약 %1분 후 합류." +ctld.i18n["ko"]["Land here"] = "여기 착륙" +ctld.i18n["ko"]["Loaded into your hold: %1"] = "화물칸 적재 완료: %1" +ctld.i18n["ko"]["No Fat Cow FARP within %1 km."] = "%1 km 이내에 Fat Cow FARP 없음." +ctld.i18n["ko"]["No friendly logistic zone to send a Fat Cow from."] = "Fat Cow를 보낼 아군 보급 구역이 없습니다." +ctld.i18n["ko"]["Not enough room: %1 crate(s) on board, %2 more needed, %3 at most."] = "공간 부족: 탑재 %1개, 추가 %2개 필요, 최대 %3개." +ctld.i18n["ko"]["Stock: %1"] = "재고: %1" +ctld.i18n["ko"]["Take off"] = "이륙" +ctld.i18n["ko"]["The Fat Cow is already on the ground."] = "Fat Cow가 이미 지상에 있습니다." +ctld.i18n["ko"]["This loadout cannot be loaded."] = "이 적재 구성은 적재할 수 없습니다." +ctld.i18n["ko"]["Unload the Fat Cow crates"] = "Fat Cow 화물 하역" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["ko"]["FARP open"] = "FARP 개방" +ctld.i18n["ko"]["following your flight"] = "편대를 따라가는 중" +ctld.i18n["ko"]["going home"] = "귀환 중" +ctld.i18n["ko"]["holding"] = "선회 대기 중" +ctld.i18n["ko"]["landing"] = "착륙 중" +ctld.i18n["ko"]["on the ground"] = "지상" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-26 +ctld.i18n["ko"]["Fat Cow RTB."] = "Fat Cow 기지로 귀환합니다." +ctld.i18n["ko"]["RTB"] = "기지로 귀환" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["ko"]["30 mm rounds"] = "30mm 탄" +ctld.i18n["ko"]["40 Hellfires (25 laser, 15 radar), 2400 rounds"] = "헬파이어 40발 (레이저 25, 레이더 15), 2400발" +ctld.i18n["ko"]["40 laser Hellfires, 2400 rounds"] = "레이저 헬파이어 40발, 2400발" +ctld.i18n["ko"]["60 Hellfires (38 laser, 22 radar), 1200 rounds"] = "헬파이어 60발 (레이저 38, 레이더 22), 1200발" +ctld.i18n["ko"]["60 laser Hellfires, 1200 rounds"] = "레이저 헬파이어 60발, 1200발" +ctld.i18n["ko"]["Fat Cow 30 mm (x1200)"] = "Fat Cow 30mm (x1200)" +ctld.i18n["ko"]["Fat Cow laser Hellfires (x20)"] = "Fat Cow 레이저 헬파이어 (x20)" +ctld.i18n["ko"]["Fat Cow radar Hellfires (x20)"] = "Fat Cow 레이더 헬파이어 (x20)" +ctld.i18n["ko"]["laser Hellfire"] = "레이저 헬파이어" +ctld.i18n["ko"]["Load"] = "적재" +ctld.i18n["ko"]["radar Hellfire"] = "레이더 헬파이어" +ctld.i18n["ko"]["Request a crate"] = "상자 요청" + +--- Keys added by generate_i18n_dicts.ps1 on 2026-10-27 +ctld.i18n["ko"]["20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds"] = "헬파이어 20발 (레이저 12, 레이더 8), 비흐르 20발, 1200 + 460발" +ctld.i18n["ko"]["20 laser Hellfires, 16 Ataka, 1200 + 500 rounds"] = "레이저 헬파이어 20발, 아타카 16발, 1200 + 500발" +ctld.i18n["ko"]["2A42 AP rounds"] = "2A42 철갑탄" +ctld.i18n["ko"]["2A42 HE rounds"] = "2A42 고폭탄" +ctld.i18n["ko"]["32 Ataka, 500 rounds (Mi-24P)"] = "아타카 32발, 500발 (Mi-24P)" +ctld.i18n["ko"]["32 Shturm, 500 rounds (Mi-24P)"] = "슈투름 32발, 500발 (Mi-24P)" +ctld.i18n["ko"]["40 Vikhr, 920 rounds (Ka-50)"] = "비흐르 40발, 920발 (Ka-50)" +ctld.i18n["ko"]["60 Vikhr, 460 rounds (Ka-50)"] = "비흐르 60발, 460발 (Ka-50)" +ctld.i18n["ko"]["Ataka"] = "아타카" +ctld.i18n["ko"]["Fat Cow 2A42 AP (x460)"] = "Fat Cow 2A42 철갑탄 (x460)" +ctld.i18n["ko"]["Fat Cow 2A42 HE (x460)"] = "Fat Cow 2A42 고폭탄 (x460)" +ctld.i18n["ko"]["Fat Cow Ataka (x16)"] = "Fat Cow 아타카 (x16)" +ctld.i18n["ko"]["Fat Cow GSh-30 (x500)"] = "Fat Cow GSh-30 (x500)" +ctld.i18n["ko"]["Fat Cow Shturm (x16)"] = "Fat Cow 슈투름 (x16)" +ctld.i18n["ko"]["Fat Cow Vikhr (x20)"] = "Fat Cow 비흐르 (x20)" +ctld.i18n["ko"]["GSh-30 rounds"] = "GSh-30 탄" +ctld.i18n["ko"]["Mixed"] = "혼합" +ctld.i18n["ko"]["Russian"] = "러시아" +ctld.i18n["ko"]["Shturm"] = "슈투름" +ctld.i18n["ko"]["Vikhr"] = "비흐르" diff --git a/src/scenes/CTLD_countrysideFarpScene.lua b/src/scenes/CTLD_countrysideFarpScene.lua index 44296d77..5caa6781 100644 --- a/src/scenes/CTLD_countrysideFarpScene.lua +++ b/src/scenes/CTLD_countrysideFarpScene.lua @@ -207,8 +207,8 @@ countrysideFarpScene.steps = { local ab = Airbase.getByName(farpName) if ab then local w = ab:getWarehouse() - -- Invisible FARP airbases (DCS built-in) return nil for getWarehouse(). - -- Only mod-based helipad FARPs have an accessible warehouse. + -- An Invisible FARP spawned at runtime does have a warehouse, and it starts + -- empty (measured in DCS 2.9.30.28738, FEAT-FAT-COW ticket 01). if w then -- If this is a redeployed FARP, restore the full snapshot; otherwise zero the warehouse. local snap = ctx.scene._params.repackData @@ -413,7 +413,7 @@ countrysideFarpScene.onRepack = function(scene, repackData) local ab = Airbase.getByName(farpName) if not ab then return end local w = ab:getWarehouse() - if not w then return end -- Invisible FARP has no warehouse + if not w then return end local inv = w:getInventory() repackData.warehouseSnapshot = { liquid = { diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua new file mode 100644 index 00000000..218046f0 --- /dev/null +++ b/src/scenes/CTLD_fatCowScene.lua @@ -0,0 +1,153 @@ +---@diagnostic disable +-- CTLD_fatCowScene.lua +-- Fat Cow FARP scene — what a landed CH-47 "Fat Cow" lays out to refuel and rearm helicopters. +-- +-- Measured in DCS 2.9.30.28738 (FEAT-FAT-COW ticket 01): a runtime Invisible FARP has a warehouse +-- and serves a player already in game, but only with a supplier in range, and the FARP Fuel Depot +-- static alone supplies both fuel and weapons. So: no vehicle, no tent, no ammo dump. +-- +-- Layout (offsets from the cow, which is the trigger unit): +-- Invisible FARP — at the cow (distance 0): the airbase whose warehouse holds the cow's stock; +-- one per cow, added again at each deployment, never destroyed +-- FARP Fuel Depot — 25 m behind the ramp (180°): the supplier +-- Ammo crates — 20 m / 135° and 20 m / 130°: dressing (never loadable: "CTLD_" name prefix) +-- Barrels — 21 m / 124°: dressing +-- 3 infantry — 25 m / 225°, standing by (everything clear of the rear rotor) +-- Warehouse — set to the cow's stock (ctx.scene._params.fatCow) once laid out +-- +-- The scene is played by CTLDFatCowManager, never requested as a crate: it has no `crate` field. +-- Dependencies: CTLDObjectRegistry, CTLDSceneManager, CTLDFatCowManager +-- ==================================================================================================== + +local fatCowScene = {} +fatCowScene.name = "Fat Cow FARP" + +-- Dressing, behind the cow to its right, clear of the rear rotor (angles clockwise from its nose). +fatCowScene.DRESSING = { + { tag = "Ammo", type = "ammo_cargo", shape_name = "ammo_box_cargo", distance = 20, angle = 135, heading = 0 }, + { tag = "Ammo", type = "ammo_cargo", shape_name = "ammo_box_cargo", distance = 20, angle = 130, heading = 30 }, + { tag = "Barrels", type = "barrels_cargo", shape_name = "barrels_cargo", distance = 21, angle = 124, heading = 0 }, +} + +fatCowScene.steps = { + + -- Step 1: the Invisible FARP at the cow, under the cow's own FARP name (ctx.scene._params.farpName). + -- Kept out of the scene's spawned objects, so packing never destroys it (evening run, 2026-10-26): + -- a destroyed heliport stays on the F10 map, and the AI cow sitting on it blew up. The next + -- deployment adds it again under the same name, at the new spot. + { + delayAfterPreviousStep = 0, + func = function(ctx) + local sc = ctx.scene + local farp = CTLDObjectRegistry.spawnObject("Invisible_FARP", sc._coalitionId, sc._countryId, + sc._refX, sc._refZ, sc._refHdgRad, { name = sc._params.farpName }) + if not farp then + sc:abort("Fat Cow FARP not spawned") + CTLDFatCowManager.getInstance():onSceneFailed(sc) + end + return true + end, + }, + + -- Step 2: the fuel depot behind the ramp — the only supplier the FARP needs. Not `critical`: the + -- scene manager would abort without telling the cow, left deploying and its pilot told nothing. + { + polar = { distance = 25, angle = 180 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "FatCow_Fuel_Depot", + func = function(ctx) + if not ctx.spawnedObj then + ctx.scene:abort("Fat Cow fuel depot not spawned") + CTLDFatCowManager.getInstance():onSceneFailed(ctx.scene) + end + return true + end, + }, + + -- Step 3: dressing — two ammo crates and a set of barrels. Spawned here rather than through the + -- registry: their DCS types are already registry entries of their own, and the "CTLD_" name prefix + -- keeps CTLDCrateManager:onBirth from registering them as loadable crates. + { + delayAfterPreviousStep = 2, + func = function(ctx) + local sc = ctx.scene + for i, d in ipairs(fatCowScene.DRESSING) do + local x, z, hdgDeg = ctld.utils.getRelativeCoords(sc._refX, sc._refZ, sc._refHdgRad, sc._refAlt, + d.angle, d.distance, d.heading, 0, sc._magDecDeg) + local ok, obj = pcall(coalition.addStaticObject, sc._countryId, { + name = string.format("CTLD_FatCow_%s_%s_%d", d.tag, sc._name, i), + type = d.type, shape_name = d.shape_name, category = "Cargos", + x = x, y = z, heading = math.rad(hdgDeg), canCargo = false, dead = false, + }) + if ok and obj then sc._spawnedObjs[#sc._spawnedObjs + 1] = obj end + end + return true + end, + }, + + -- Step 4: the crew standing by. + { + polar = { distance = 25, angle = 225 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "FatCow_Crew", + }, + + -- Step 5: stock the warehouse and tell the manager the FARP is open. + { + delayAfterPreviousStep = 0, + func = function(ctx) + CTLDFatCowManager.getInstance():onSceneLaidOut(ctx.scene) + return true + end, + }, +} + +-- ==================================================================================================== +-- Registry entries required by this scene (registerIfAbsent: shared keys are left as declared). +-- ==================================================================================================== + +-- Shape from DCS's db.Units.lua ("FARP Fuel Depot", ShapeName "GSM Rus"). +CTLDObjectRegistry.registerIfAbsent("FatCow_Fuel_Depot", { + groupType = "STATIC", + namePrefix = "FatCow_Fuel_Depot", + type = "FARP Fuel Depot", + shape_name = "GSM Rus", + category = "Fortifications", +}) + +CTLDObjectRegistry.registerIfAbsent("FatCow_Crew", { + groupType = "GROUND", + namePrefix = "FatCow_Crew_Grp", + task = "Ground Nothing", + category = Unit.Category.GROUND_UNIT, + units = { + { + namePrefix = "FatCow_Crew", + unitType = function(cid) return cid == coalition.side.RED and "Infantry AK" or "Soldier M4" end, + playerCanDrive = false, + dx = 0, dz = 0, dh = 0, + }, + { + namePrefix = "FatCow_Crew", + unitType = function(cid) return cid == coalition.side.RED and "Infantry AK" or "Soldier M4" end, + playerCanDrive = false, + dx = 2, dz = 1, dh = 0, + }, + { + namePrefix = "FatCow_Crew", + unitType = function(cid) return cid == coalition.side.RED and "Infantry AK" or "Soldier M4" end, + playerCanDrive = false, + dx = -1, dz = 2, dh = 0, + }, + }, +}) + +-- ==================================================================================================== +-- Self-registration +-- ==================================================================================================== + +CTLDSceneManager.getInstance():registerSceneModel(fatCowScene) diff --git a/tests/ci/data/catalogue_shapes/2.3.0.json b/tests/ci/data/catalogue_shapes/2.3.0.json new file mode 100644 index 00000000..b6c253df --- /dev/null +++ b/tests/ci/data/catalogue_shapes/2.3.0.json @@ -0,0 +1,321 @@ +{ + "version": "2.3.0", + "keys": [ + "AASystemCrateStacking", + "AASystemLimitBLUE", + "AASystemLimitRED", + "CIV_WEIGHT", + "JTAC_LIMIT_BLUE", + "JTAC_LIMIT_RED", + "JTAC_WEIGHT", + "JTAC_allow9Line", + "JTAC_allowSmokeRequest", + "JTAC_allowStandbyMode", + "JTAC_droneAltitude", + "JTAC_droneRadiusNoLase", + "JTAC_droneRadiusOnLase", + "JTAC_droneSpeed", + "JTAC_dropEnabled", + "JTAC_jtacStatusF10", + "JTAC_laseIntervalSeconds", + "JTAC_laseSpotCorrections", + "JTAC_location", + "JTAC_lock", + "JTAC_maxDistance", + "JTAC_searchIntervalSeconds", + "JTAC_smokeColour_BLUE", + "JTAC_smokeColour_RED", + "JTAC_smokeMarginOfError", + "JTAC_smokeOffset_x", + "JTAC_smokeOffset_y", + "JTAC_smokeOffset_z", + "JTAC_smokeOn_BLUE", + "JTAC_smokeOn_RED", + "JTAC_targetDeconfliction", + "KIT_WEIGHT", + "MANPAD_WEIGHT", + "MG_WEIGHT", + "MORTAR_WEIGHT", + "RIFLE_WEIGHT", + "RPG_WEIGHT", + "SOLDIER_WEIGHT", + "aaAssemblyDistance", + "aaLaunchers", + "aaRearmDistance", + "addPlayerAircraftByType", + "aiZones", + "allowRandomAiTeamPickups", + "autoUnpackRadiusParachute", + "beaconAutoRefreshLayer", + "beaconIconColor", + "beaconIconRadius", + "beaconLayerEnabled", + "beaconRefreshInterval", + "beaconRemovalRadius", + "beaconTextSize", + "capabilitiesByType", + "crateDropExtraDistance", + "crateSpacing", + "crateSpawnGap", + "ctldLogPath", + "debug", + "debugScreenLog", + "debugScreenLogDuration", + "defaultVehicleWeight", + "defaultZoneRadius", + "demineRadius", + "deployedBeaconBattery", + "disableAllSmoke", + "dynamicZoneRadius", + "enableAllCrates", + "enableAutoOrbitingFlyingJtacOnTarget", + "enableCrates", + "enableFARPRepack", + "enableFastRopeInsertion", + "enableHoverSlingload", + "enablePackingVehicles", + "enableParachuteDrop", + "enableSmokeDrop", + "enabledFOBBuilding", + "enabledRadioBeaconDrop", + "extractableGroups", + "farpTroopPickupRadius", + "fastRopeMaximumHeight", + "fatCowAICows", + "fatCowAIType", + "fatCowCrates", + "fatCowDeployDelay", + "fatCowEnabled", + "fatCowLoadouts", + "fatCowTypes", + "fieldExtractTroopWeight", + "fobCrateCollectionRadius", + "fobDestructionThreshold", + "fobLogisticZoneRadius", + "fobMinDistanceFromZones", + "fobTroopPickupRadius", + "groundAglThreshold", + "groundVehicleWeights", + "hoverTime", + "jtacLaserCodeMax", + "jtacLaserCodeMin", + "loadCrateFromMenu", + "loadCrateSearchRadius", + "loadableGroups", + "location_DMS", + "logisticUnitTypes", + "logisticUnits", + "maxDistanceFromCrate", + "maxExtractDistance", + "maxSlingloadSpeed", + "maxTransportWeight", + "maximumDistanceLogistic", + "maximumDistancePackableUnitsSearch", + "maximumHoverHeight", + "maximumSearchDistance", + "minimumHoverHeight", + "modTypes", + "nbLimitSpawnedTroops", + "numberOfTroops", + "parachuteDescentRateCrates", + "parachuteDescentRateTroops", + "parachuteDescentRateVehicles", + "parachuteInertiaFactor", + "parachuteLateralDriftMax", + "parachuteLateralDriftMin", + "parachuteMinAltitudeCrates", + "parachuteMinAltitudeTroops", + "parachuteMinAltitudeVehicles", + "radioSound", + "radioSoundFC3", + "reconEnabled", + "reconF10Menu", + "reconIconScale", + "reconMinAltitude", + "reconRefreshInterval", + "reconSearchRadius", + "showMinefieldOnF10Map", + "slingCutDestroyHeight", + "slingLoad", + "smokeAutoResume", + "smokeAutoResumeInterval", + "smokeRefreshInterval", + "spawnDistanceInCircle", + "spawnableCrates", + "spawnableCratesModels", + "transportPilotNames", + "troopPickupAtFARP", + "troopPickupAtFOB", + "troopZoneShipTypes", + "troopZones", + "unpackSearchRadius", + "wpZones" + ], + "entryFields": [ + "capabilitiesByType/76MD/canParachuteDrop", + "capabilitiesByType/76MD/canSlingload", + "capabilitiesByType/76MD/canTransportWholeVehicle", + "capabilitiesByType/76MD/convertNativeLoadToCTLD", + "capabilitiesByType/76MD/cratesEnabled", + "capabilitiesByType/76MD/maxCratesOnboard", + "capabilitiesByType/76MD/maxTroopsOnboard", + "capabilitiesByType/76MD/maxVehicleWeight", + "capabilitiesByType/76MD/maxWholeVehiclesOnboard", + "capabilitiesByType/76MD/troopsEnabled", + "capabilitiesByType/76MD/useNativeDcsCargoSystem", + "capabilitiesByType/C-130J-30/canParachuteDrop", + "capabilitiesByType/C-130J-30/canSlingload", + "capabilitiesByType/C-130J-30/canTransportWholeVehicle", + "capabilitiesByType/C-130J-30/convertNativeLoadToCTLD", + "capabilitiesByType/C-130J-30/crateSpawnDistance", + "capabilitiesByType/C-130J-30/crateSpawnSector", + "capabilitiesByType/C-130J-30/cratesEnabled", + "capabilitiesByType/C-130J-30/maxCratesOnboard", + "capabilitiesByType/C-130J-30/maxTroopsOnboard", + "capabilitiesByType/C-130J-30/maxVehicleWeight", + "capabilitiesByType/C-130J-30/maxWholeVehiclesOnboard", + "capabilitiesByType/C-130J-30/troopsEnabled", + "capabilitiesByType/C-130J-30/useNativeDcsCargoSystem", + "capabilitiesByType/CH-47Fbl1/canParachuteDrop", + "capabilitiesByType/CH-47Fbl1/canSlingload", + "capabilitiesByType/CH-47Fbl1/canTransportWholeVehicle", + "capabilitiesByType/CH-47Fbl1/convertNativeLoadToCTLD", + "capabilitiesByType/CH-47Fbl1/crateSpawnDistance", + "capabilitiesByType/CH-47Fbl1/crateSpawnSector", + "capabilitiesByType/CH-47Fbl1/cratesEnabled", + "capabilitiesByType/CH-47Fbl1/maxCratesOnboard", + "capabilitiesByType/CH-47Fbl1/maxTroopsOnboard", + "capabilitiesByType/CH-47Fbl1/maxVehicleWeight", + "capabilitiesByType/CH-47Fbl1/maxWholeVehiclesOnboard", + "capabilitiesByType/CH-47Fbl1/troopsEnabled", + "capabilitiesByType/CH-47Fbl1/useNativeDcsCargoSystem", + "capabilitiesByType/Hercules/canParachuteDrop", + "capabilitiesByType/Hercules/canSlingload", + "capabilitiesByType/Hercules/canTransportWholeVehicle", + "capabilitiesByType/Hercules/convertNativeLoadToCTLD", + "capabilitiesByType/Hercules/cratesEnabled", + "capabilitiesByType/Hercules/maxCratesOnboard", + "capabilitiesByType/Hercules/maxTroopsOnboard", + "capabilitiesByType/Hercules/maxVehicleWeight", + "capabilitiesByType/Hercules/maxWholeVehiclesOnboard", + "capabilitiesByType/Hercules/troopsEnabled", + "capabilitiesByType/Hercules/useNativeDcsCargoSystem", + "capabilitiesByType/Mi-24P/canParachuteDrop", + "capabilitiesByType/Mi-24P/canSlingload", + "capabilitiesByType/Mi-24P/canTransportWholeVehicle", + "capabilitiesByType/Mi-24P/convertNativeLoadToCTLD", + "capabilitiesByType/Mi-24P/crateSpawnDistance", + "capabilitiesByType/Mi-24P/crateSpawnSector", + "capabilitiesByType/Mi-24P/cratesEnabled", + "capabilitiesByType/Mi-24P/maxCratesOnboard", + "capabilitiesByType/Mi-24P/maxTroopsOnboard", + "capabilitiesByType/Mi-24P/maxWholeVehiclesOnboard", + "capabilitiesByType/Mi-24P/troopsEnabled", + "capabilitiesByType/Mi-24P/useNativeDcsCargoSystem", + "capabilitiesByType/Mi-8MT/canParachuteDrop", + "capabilitiesByType/Mi-8MT/canSlingload", + "capabilitiesByType/Mi-8MT/canTransportWholeVehicle", + "capabilitiesByType/Mi-8MT/convertNativeLoadToCTLD", + "capabilitiesByType/Mi-8MT/crateSpawnDistance", + "capabilitiesByType/Mi-8MT/crateSpawnSector", + "capabilitiesByType/Mi-8MT/cratesEnabled", + "capabilitiesByType/Mi-8MT/maxCratesOnboard", + "capabilitiesByType/Mi-8MT/maxTroopsOnboard", + "capabilitiesByType/Mi-8MT/maxVehicleWeight", + "capabilitiesByType/Mi-8MT/maxWholeVehiclesOnboard", + "capabilitiesByType/Mi-8MT/troopsEnabled", + "capabilitiesByType/Mi-8MT/useNativeDcsCargoSystem", + "capabilitiesByType/SA342L/canParachuteDrop", + "capabilitiesByType/SA342L/canSlingload", + "capabilitiesByType/SA342L/canTransportWholeVehicle", + "capabilitiesByType/SA342L/convertNativeLoadToCTLD", + "capabilitiesByType/SA342L/cratesEnabled", + "capabilitiesByType/SA342L/maxCratesOnboard", + "capabilitiesByType/SA342L/maxTroopsOnboard", + "capabilitiesByType/SA342L/maxWholeVehiclesOnboard", + "capabilitiesByType/SA342L/troopsEnabled", + "capabilitiesByType/SA342L/useNativeDcsCargoSystem", + "capabilitiesByType/SA342M/canParachuteDrop", + "capabilitiesByType/SA342M/canSlingload", + "capabilitiesByType/SA342M/canTransportWholeVehicle", + "capabilitiesByType/SA342M/convertNativeLoadToCTLD", + "capabilitiesByType/SA342M/cratesEnabled", + "capabilitiesByType/SA342M/maxCratesOnboard", + "capabilitiesByType/SA342M/maxTroopsOnboard", + "capabilitiesByType/SA342M/maxWholeVehiclesOnboard", + "capabilitiesByType/SA342M/troopsEnabled", + "capabilitiesByType/SA342M/useNativeDcsCargoSystem", + "capabilitiesByType/SA342Minigun/canParachuteDrop", + "capabilitiesByType/SA342Minigun/canSlingload", + "capabilitiesByType/SA342Minigun/canTransportWholeVehicle", + "capabilitiesByType/SA342Minigun/convertNativeLoadToCTLD", + "capabilitiesByType/SA342Minigun/cratesEnabled", + "capabilitiesByType/SA342Minigun/maxCratesOnboard", + "capabilitiesByType/SA342Minigun/maxTroopsOnboard", + "capabilitiesByType/SA342Minigun/maxWholeVehiclesOnboard", + "capabilitiesByType/SA342Minigun/troopsEnabled", + "capabilitiesByType/SA342Minigun/useNativeDcsCargoSystem", + "capabilitiesByType/SA342Mistral/canParachuteDrop", + "capabilitiesByType/SA342Mistral/canSlingload", + "capabilitiesByType/SA342Mistral/canTransportWholeVehicle", + "capabilitiesByType/SA342Mistral/convertNativeLoadToCTLD", + "capabilitiesByType/SA342Mistral/cratesEnabled", + "capabilitiesByType/SA342Mistral/maxCratesOnboard", + "capabilitiesByType/SA342Mistral/maxTroopsOnboard", + "capabilitiesByType/SA342Mistral/maxWholeVehiclesOnboard", + "capabilitiesByType/SA342Mistral/troopsEnabled", + "capabilitiesByType/SA342Mistral/useNativeDcsCargoSystem", + "capabilitiesByType/SK-60/canParachuteDrop", + "capabilitiesByType/SK-60/canSlingload", + "capabilitiesByType/SK-60/canTransportWholeVehicle", + "capabilitiesByType/SK-60/convertNativeLoadToCTLD", + "capabilitiesByType/SK-60/cratesEnabled", + "capabilitiesByType/SK-60/maxCratesOnboard", + "capabilitiesByType/SK-60/maxTroopsOnboard", + "capabilitiesByType/SK-60/maxWholeVehiclesOnboard", + "capabilitiesByType/SK-60/troopsEnabled", + "capabilitiesByType/SK-60/useNativeDcsCargoSystem", + "capabilitiesByType/UH-1H/canParachuteDrop", + "capabilitiesByType/UH-1H/canSlingload", + "capabilitiesByType/UH-1H/canTransportWholeVehicle", + "capabilitiesByType/UH-1H/convertNativeLoadToCTLD", + "capabilitiesByType/UH-1H/crateSpawnDistance", + "capabilitiesByType/UH-1H/crateSpawnSector", + "capabilitiesByType/UH-1H/cratesEnabled", + "capabilitiesByType/UH-1H/maxCratesOnboard", + "capabilitiesByType/UH-1H/maxTroopsOnboard", + "capabilitiesByType/UH-1H/maxVehicleWeight", + "capabilitiesByType/UH-1H/maxWholeVehiclesOnboard", + "capabilitiesByType/UH-1H/troopsEnabled", + "capabilitiesByType/UH-1H/useNativeDcsCargoSystem", + "capabilitiesByType/UH-60L/canParachuteDrop", + "capabilitiesByType/UH-60L/canSlingload", + "capabilitiesByType/UH-60L/canTransportWholeVehicle", + "capabilitiesByType/UH-60L/convertNativeLoadToCTLD", + "capabilitiesByType/UH-60L/cratesEnabled", + "capabilitiesByType/UH-60L/maxCratesOnboard", + "capabilitiesByType/UH-60L/maxTroopsOnboard", + "capabilitiesByType/UH-60L/maxWholeVehiclesOnboard", + "capabilitiesByType/UH-60L/troopsEnabled", + "capabilitiesByType/UH-60L/useNativeDcsCargoSystem", + "capabilitiesByType/Yak-52/canParachuteDrop", + "capabilitiesByType/Yak-52/canSlingload", + "capabilitiesByType/Yak-52/canTransportWholeVehicle", + "capabilitiesByType/Yak-52/convertNativeLoadToCTLD", + "capabilitiesByType/Yak-52/cratesEnabled", + "capabilitiesByType/Yak-52/maxCratesOnboard", + "capabilitiesByType/Yak-52/maxTroopsOnboard", + "capabilitiesByType/Yak-52/maxWholeVehiclesOnboard", + "capabilitiesByType/Yak-52/troopsEnabled", + "capabilitiesByType/Yak-52/useNativeDcsCargoSystem", + "spawnableCratesModels/dynamic/canCargo", + "spawnableCratesModels/dynamic/size", + "spawnableCratesModels/dynamic/type", + "spawnableCratesModels/load/canCargo", + "spawnableCratesModels/load/size", + "spawnableCratesModels/load/type", + "spawnableCratesModels/sling/canCargo", + "spawnableCratesModels/sling/shape_name", + "spawnableCratesModels/sling/type" + ] +} diff --git a/tests/ci/data/config_defaults.json b/tests/ci/data/config_defaults.json index 319a4ae1..7243ad05 100644 --- a/tests/ci/data/config_defaults.json +++ b/tests/ci/data/config_defaults.json @@ -296,7 +296,7 @@ "useNativeDcsCargoSystem": false } }, - "configVersion": "2.2.0", + "configVersion": "2.3.0", "crateDropExtraDistance": 2, "crateSpacing": 5, "crateSpawnGap": 0.5, @@ -350,6 +350,262 @@ ], "farpTroopPickupRadius": 150, "fastRopeMaximumHeight": 18.28, + "fatCowAICows": [], + "fatCowAIType": "CH-47Fbl1", + "fatCowCrates": [ + { + "desc": "Fat Cow fuel tank (800 gal)", + "fuelKg": 2400, + "weight": 2400 + }, + { + "count": 20, + "desc": "Fat Cow laser Hellfires (x20)", + "item": "weapons.missiles.AGM_114K", + "weight": 906 + }, + { + "count": 20, + "desc": "Fat Cow radar Hellfires (x20)", + "item": "weapons.missiles.AGM_114", + "weight": 980 + }, + { + "count": 1200, + "desc": "Fat Cow 30 mm (x1200)", + "item": "weapons.shells.M230_HEDP M789", + "weight": 600 + }, + { + "count": 20, + "desc": "Fat Cow Vikhr (x20)", + "item": "weapons.missiles.Vikhr_M", + "weight": 900 + }, + { + "count": 16, + "desc": "Fat Cow Ataka (x16)", + "item": "weapons.missiles.Ataka_9M120", + "weight": 792 + }, + { + "count": 16, + "desc": "Fat Cow Shturm (x16)", + "item": "weapons.missiles.AT_6", + "weight": 566 + }, + { + "count": 460, + "desc": "Fat Cow 2A42 HE (x460)", + "item": "weapons.shells.2A42_30_HE", + "weight": 391 + }, + { + "count": 460, + "desc": "Fat Cow 2A42 AP (x460)", + "item": "weapons.shells.2A42_30_AP", + "weight": 391 + }, + { + "count": 500, + "desc": "Fat Cow GSh-30 (x500)", + "item": "weapons.shells.GSh_30_2K_HE", + "weight": 425 + } + ], + "fatCowDeployDelay": 60, + "fatCowEnabled": true, + "fatCowLoadouts": [ + { + "desc": "40 Hellfires (25 laser, 15 radar), 2400 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 25, + "item": "weapons.missiles.AGM_114K" + }, + { + "count": 15, + "item": "weapons.missiles.AGM_114" + }, + { + "count": 2400, + "item": "weapons.shells.M230_HEDP M789" + } + ] + }, + { + "desc": "60 Hellfires (38 laser, 22 radar), 1200 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 38, + "item": "weapons.missiles.AGM_114K" + }, + { + "count": 22, + "item": "weapons.missiles.AGM_114" + }, + { + "count": 1200, + "item": "weapons.shells.M230_HEDP M789" + } + ] + }, + { + "desc": "40 laser Hellfires, 2400 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 40, + "item": "weapons.missiles.AGM_114K" + }, + { + "count": 2400, + "item": "weapons.shells.M230_HEDP M789" + } + ] + }, + { + "desc": "60 laser Hellfires, 1200 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 60, + "item": "weapons.missiles.AGM_114K" + }, + { + "count": 1200, + "item": "weapons.shells.M230_HEDP M789" + } + ] + }, + { + "desc": "40 Vikhr, 920 rounds (Ka-50)", + "fuelKg": 4800, + "items": [ + { + "count": 40, + "item": "weapons.missiles.Vikhr_M" + }, + { + "count": 460, + "item": "weapons.shells.2A42_30_HE" + }, + { + "count": 460, + "item": "weapons.shells.2A42_30_AP" + } + ], + "menu": "russian" + }, + { + "desc": "60 Vikhr, 460 rounds (Ka-50)", + "fuelKg": 4800, + "items": [ + { + "count": 60, + "item": "weapons.missiles.Vikhr_M" + }, + { + "count": 230, + "item": "weapons.shells.2A42_30_HE" + }, + { + "count": 230, + "item": "weapons.shells.2A42_30_AP" + } + ], + "menu": "russian" + }, + { + "desc": "32 Ataka, 500 rounds (Mi-24P)", + "fuelKg": 4800, + "items": [ + { + "count": 32, + "item": "weapons.missiles.Ataka_9M120" + }, + { + "count": 500, + "item": "weapons.shells.GSh_30_2K_HE" + } + ], + "menu": "russian" + }, + { + "desc": "32 Shturm, 500 rounds (Mi-24P)", + "fuelKg": 4800, + "items": [ + { + "count": 32, + "item": "weapons.missiles.AT_6" + }, + { + "count": 500, + "item": "weapons.shells.GSh_30_2K_HE" + } + ], + "menu": "russian" + }, + { + "desc": "20 Hellfires (12 laser, 8 radar), 20 Vikhr, 1200 + 460 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 12, + "item": "weapons.missiles.AGM_114K" + }, + { + "count": 8, + "item": "weapons.missiles.AGM_114" + }, + { + "count": 20, + "item": "weapons.missiles.Vikhr_M" + }, + { + "count": 1200, + "item": "weapons.shells.M230_HEDP M789" + }, + { + "count": 230, + "item": "weapons.shells.2A42_30_HE" + }, + { + "count": 230, + "item": "weapons.shells.2A42_30_AP" + } + ], + "menu": "mixed" + }, + { + "desc": "20 laser Hellfires, 16 Ataka, 1200 + 500 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 20, + "item": "weapons.missiles.AGM_114K" + }, + { + "count": 16, + "item": "weapons.missiles.Ataka_9M120" + }, + { + "count": 1200, + "item": "weapons.shells.M230_HEDP M789" + }, + { + "count": 500, + "item": "weapons.shells.GSh_30_2K_HE" + } + ], + "menu": "mixed" + } + ], + "fatCowTypes": [ + "CH-47Fbl1" + ], "fieldExtractTroopWeight": 130, "fobCrateCollectionRadius": 750, "fobDestructionThreshold": 0.5, diff --git a/tests/ci/helpers/loader.lua b/tests/ci/helpers/loader.lua index 0cefe0cb..e308d7e5 100644 --- a/tests/ci/helpers/loader.lua +++ b/tests/ci/helpers/loader.lua @@ -62,6 +62,7 @@ dofile(SRC .. "CTLD_beacon.lua") dofile(SRC .. "CTLD_recon.lua") dofile(SRC .. "CTLD_jtac.lua") dofile(SRC .. "CTLD_player.lua") +dofile(SRC .. "CTLD_fatCow.lua") -- ── Orchestrator ────────────────────────────────────────────── dofile(SRC .. "CTLD_core.lua") diff --git a/tests/ci/unit/fatcow_ai_spec.lua b/tests/ci/unit/fatcow_ai_spec.lua new file mode 100644 index 00000000..4055d01f --- /dev/null +++ b/tests/ci/unit/fatcow_ai_spec.lua @@ -0,0 +1,376 @@ +---@diagnostic disable +-- tests/ci/unit/fatcow_ai_spec.lua +-- FEAT-FAT-COW ticket 04: an AI cow comes from the nearest friendly logistic zone and follows a flight. +-- A player flight commands it from F10 (Land here, Take off, Hold here, Join us); an AI flight's cow +-- lands and takes off with the leader. It deploys after the delay, goes home when released. +-- ============================================================ + +local HELLFIRE = "weapons.missiles.AGM_114K" +local RADAR = "weapons.missiles.AGM_114" + +describe("AI Fat Cow", function() + local mgr, sm, w, groups, units, scheduled, messages, added, inZone, saved + + local function newUnit(name, groupName, x, z) + local u = { + airborne = false, _name = name, _group = groupName, _p = { x = x or 0, y = 20, z = z or 0 }, + } + function u:getName() return self._name end + function u:isExist() return true end + function u:getCoalition() return coalition.side.BLUE end + function u:getCountry() return 2 end + function u:getPoint() return self._p end + function u:getPosition() return { x = { x = 1, y = 0, z = 0 }, p = self._p } end + function u:inAir() return self.airborne end + function u:getGroup() return groups[self._group] end + units[name] = u + return u + end + + local function newGroup(name, id, unitName) + local g = { _name = name, _id = id, tasks = {}, destroyed = false, activated = false } + function g:getName() return self._name end + function g:getID() return self._id end + function g:isExist() return not self.destroyed end + function g:getUnit() return units[unitName] end + function g:getUnits() + local out = {} + for _, u in pairs(units) do if u._group == name then out[#out + 1] = u end end + return out + end + function g:getController() + local grp = self + return { setTask = function(_, t) grp.tasks[#grp.tasks + 1] = t end } + end + function g:activate() self.activated = true end + function g:destroy() self.destroyed = true end + groups[name] = g + return g + end + + local function lastTask(groupName) + local t = groups[groupName].tasks + return t[#t] + end + + local function runScheduled() + local list = scheduled + scheduled = {} + for _, fn in ipairs(list) do fn() end + end + + before_each(function() + CTLDFatCowManager._instance = nil + mgr = CTLDFatCowManager.getInstance() + mgr._started = true + sm = CTLDSceneManager.getInstance() + groups, units, scheduled, messages, added, inZone = {}, {}, {}, {}, {}, true + w = { liquids = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 }, items = {} } + function w:getLiquidAmount(t) return self.liquids[t] end + function w:setLiquidAmount(t, v) self.liquids[t] = v end + function w:getItemCount(n) return self.items[n] or 0 end + function w:setItem(n, v) self.items[n] = v end + local zm = CTLDZoneManager.getInstance() + saved = { + unitGet = Unit.getByName, groupGet = Group.getByName, addGroup = coalition.addGroup, + schedule = ctld.scheduler.schedule, remove = ctld.scheduler.remove, + inAir = ctld.utils.inAir, gid = ctld.utils.getGroupId, outGroup = trigger.action.outTextForGroup, + play = sm.playScene, pack = sm.packScene, airbase = Airbase.getByName, + zone = zm.getLogisticZonesForCoalition, gs = ctld.gs, cargo = trigger.action.setUnitInternalCargo, + } + Unit.getByName = function(n) return units[n] end + Group.getByName = function(n) return groups[n] end + coalition.addGroup = function(_, _, data) + added[#added + 1] = data + newUnit(data.units[1].name, data.name, data.units[1].x, data.units[1].y) + return newGroup(data.name, 100 + #added, data.units[1].name) + end + ctld.scheduler.schedule = function(fn) scheduled[#scheduled + 1] = fn; return #scheduled end + ctld.scheduler.remove = function(id) scheduled[id] = function() end end + ctld.utils.inAir = function(u) return u.airborne end + ctld.utils.getGroupId = function(u) return u:getGroup():getID() end + trigger.action.outTextForGroup = function(gid, text) messages[#messages + 1] = { gid = gid, text = text } end + trigger.action.setUnitInternalCargo = function() end + zm.getLogisticZonesForCoalition = function() + if not inZone then return {} end + return { { name = "Akrotiri", getCenter = function() return { x = 5000, y = 10, z = 2000 } end } } + end + sm.playScene = function(_, u, model, params) + local scene = { _name = "Fat Cow FARP#1", _params = params } + mgr:onSceneLaidOut(scene) + return scene + end + sm.packScene = function() return {} end + Airbase.getByName = function(n) if n == "CTLD_FatCow_FARP_1" then return { getWarehouse = function() return w end } end end + local settings = { fatCowAIType = "CH-47Fbl1", fatCowDeployDelay = 60, fatCowAICows = {}, + fatCowLoadouts = { + { desc = "Six", fuelKg = 7200, items = { { item = HELLFIRE, count = 6 } } }, + { desc = "Radar", fuelKg = 4800, items = { { item = RADAR, count = 20 } } }, + } } + ctld.gs = function(k) if settings[k] ~= nil then return settings[k] end return saved.gs(k) end + mgr._settings = settings + end) + + after_each(function() + local zm = CTLDZoneManager.getInstance() + Unit.getByName, Group.getByName, coalition.addGroup = saved.unitGet, saved.groupGet, saved.addGroup + ctld.scheduler.schedule, ctld.scheduler.remove = saved.schedule, saved.remove + ctld.utils.inAir, ctld.utils.getGroupId = saved.inAir, saved.gid + trigger.action.outTextForGroup, trigger.action.setUnitInternalCargo = saved.outGroup, saved.cargo + sm.playScene, sm.packScene, Airbase.getByName = saved.play, saved.pack, saved.airbase + zm.getLogisticZonesForCoalition, ctld.gs = saved.zone, saved.gs + CTLDFatCowManager._instance = nil + end) + + -- An Apache flight "Gunfighter" (group id 7) whose leader requests a cow, and the cow ready. + local function flightWithCow(player) + newGroup("Gunfighter", 7, "Gunfighter-1") + local leader = newUnit("Gunfighter-1", "Gunfighter", 1000, 2000) + if player then function leader:getPlayerName() return "Zip" end end + mgr:requestAICow("Gunfighter-1", 1) + runScheduled() + local cow = mgr:aiCowOfFlight("Gunfighter") + return leader, cow + end + + it("comes airborne from the nearest friendly logistic zone, with fuel, and follows the flight", function() + local leader, cow = flightWithCow() + leader.airborne = true + assert.equals(1, #added) + assert.equals("CH-47Fbl1", added[1].units[1].type) + assert.equals("Turning Point", added[1].route.points[1].type) + assert.equals(5000, added[1].units[1].x) + assert.equals(2444, added[1].units[1].payload.fuel) -- 80 % of the CH-47F's internal fuel + assert.is_not_nil(cow) + assert.equals(7200, cow.stock.fuelKg) + assert.equals(6, cow.stock.items[HELLFIRE]) + local t = lastTask(cow.ai.groupName) + assert.equals("Follow", t.id) + assert.equals(7, t.params.groupId) + end) + + it("carries the loadout the flight picked", function() + newGroup("Gunfighter", 7, "Gunfighter-1") + newUnit("Gunfighter-1", "Gunfighter") + mgr:requestAICow("Gunfighter-1", 2) + runScheduled() + local cow = mgr:aiCowOfFlight("Gunfighter") + assert.equals(4800, cow.stock.fuelKg) + assert.equals(20, cow.stock.items[RADAR]) + assert.is_nil(cow.stock.items[HELLFIRE]) + mgr:requestAICow("Gunfighter-1", 9) -- no such loadout: nothing comes + assert.equals(1, #added) + end) + + it("can be requested in the air, but not without a friendly logistic zone, nor twice", function() + newGroup("Gunfighter", 7, "Gunfighter-1") + local leader = newUnit("Gunfighter-1", "Gunfighter") + leader.airborne = true + inZone = false + mgr:requestAICow("Gunfighter-1", 1) + assert.equals(0, #added) + inZone = true + mgr:requestAICow("Gunfighter-1", 1) + runScheduled() + mgr:requestAICow("Gunfighter-1", 1) + assert.equals(1, #added) + assert.is_not_nil(messages[2].text:find("Akrotiri", 1, true)) + end) + + it("does nothing on its own for a player flight: the leader landing or taking off moves it not", function() + local leader, cow = flightWithCow(true) + units[cow.unitName].airborne = true + mgr:onLand({ initiator = leader }) + assert.equals("Follow", lastTask(cow.ai.groupName).id) + mgr:orderLandHere("Gunfighter-1") + assert.equals("Mission", lastTask(cow.ai.groupName).id) + units[cow.unitName].airborne = false + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + assert.equals(CTLDFatCow.STATE_DEPLOYED, cow.state) + leader.airborne = true + mgr:onTakeoff({ initiator = leader }) -- hopping onto the FARP + assert.equals(CTLDFatCow.STATE_DEPLOYED, cow.state) + end) + + it("obeys Take off, Hold here and Join us, packing its FARP first", function() + local leader, cow = flightWithCow(true) + units[cow.unitName].airborne = true + mgr:orderLandHere("Gunfighter-1") + units[cow.unitName].airborne = false + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + w.liquids[0] = 5000 + mgr:orderTakeOff("Gunfighter-1") + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + assert.equals(5000, cow.stock.fuelKg) + assert.equals("Orbit", lastTask(cow.ai.groupName).id) + mgr:orderHoldHere("Gunfighter-1") + local orbit = lastTask(cow.ai.groupName) + assert.equals("Orbit", orbit.id) + assert.equals(leader._p.x, orbit.params.point.x) + mgr:orderJoin("Gunfighter-1") + assert.equals("Follow", lastTask(cow.ai.groupName).id) + end) + + it("reports its stock before it has landed", function() + local _, cow = flightWithCow(true) + mgr:reportFlightCowStatus("Gunfighter-1") + local text = messages[#messages].text + assert.is_not_nil(text:find("7200 kg", 1, true)) + assert.is_not_nil(text:find("laser Hellfire x6", 1, true)) + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + end) + + it("lands next to the leader with a Land task, and deploys only after the delay", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + local t = lastTask(cow.ai.groupName) + assert.equals("Mission", t.id) + assert.equals("Land", t.params.route.points[1].task.params.tasks[1].id) + mgr:onLand({ initiator = units[cow.unitName] }) + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + runScheduled() + assert.equals(CTLDFatCow.STATE_DEPLOYED, cow.state) + assert.equals(7200, w.liquids[0]) + assert.equals(7, messages[#messages].gid) + end) + + it("packs and follows again when the leader takes off", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + w.liquids[0] = 6000 + leader.airborne = true + mgr:onTakeoff({ initiator = leader }) + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + assert.equals(6000, cow.stock.fuelKg) + assert.equals("Follow", lastTask(cow.ai.groupName).id) + end) + + it("ignores a wingman: only the leader moves the cow or closes its FARP", function() + local leader, cow = flightWithCow() + local wingman = newUnit("Gunfighter-2", "Gunfighter", 1000, 2050) + mgr:onLand({ initiator = wingman }) + assert.equals("Follow", lastTask(cow.ai.groupName).id) + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + wingman.airborne = true + mgr:onTakeoff({ initiator = wingman }) + assert.equals(CTLDFatCow.STATE_DEPLOYED, cow.state) + end) + + it("does not deploy when the leader took off before the delay ran out", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + mgr:onTakeoff({ initiator = leader }) + runScheduled() + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + assert.equals("Follow", lastTask(cow.ai.groupName).id) + end) + + it("goes home when emptied, and despawns once landed there", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + w.liquids[0], w.items[HELLFIRE] = 0, 0 + mgr:onTakeoff({ initiator = leader }) + assert.equals(CTLDFatCowManager.PHASE_RETURNING, cow.ai.phase) + assert.equals("Mission", lastTask(cow.ai.groupName).id) + assert.is_nil(mgr:aiCowOfFlight("Gunfighter")) + mgr:onLand({ initiator = units[cow.unitName] }) + assert.is_true(groups[cow.ai.groupName].destroyed) + assert.is_nil(mgr:getCow(cow.unitName)) + end) + + it("goes home, packed, when released", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + mgr:releaseAICow("Gunfighter-1") + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + assert.equals(CTLDFatCowManager.PHASE_RETURNING, cow.ai.phase) + end) + + it("goes home, packed, when its flight is gone", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + groups["Gunfighter"].destroyed = true + mgr:onDead({ initiator = leader }) + runScheduled() + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + assert.equals(CTLDFatCowManager.PHASE_RETURNING, cow.ai.phase) + assert.is_nil(mgr:aiCowOfFlight("Gunfighter")) + end) + + it("waits for a late-activated escort to be activated", function() + mgr._settings.fatCowAICows = { { group = "Cow Group", escort = "Gunfighter" } } + newGroup("Cow Group", 50, "Cow-1") + newUnit("Cow-1", "Cow Group") + newGroup("Gunfighter", 7, "Gunfighter-1") + local leader = newUnit("Gunfighter-1", "Gunfighter") + leader.active = false + function leader:isActive() return self.active end + mgr:_activateMissionCows() + assert.is_false(groups["Cow Group"].activated) + leader.active = true + mgr:onBirth({ initiator = leader }) + assert.is_true(groups["Cow Group"].activated) + end) + + it("activates a mission-placed cow once its escorted flight is in the world", function() + mgr._settings.fatCowAICows = { { group = "Cow Group", escort = "Gunfighter" } } + newGroup("Cow Group", 50, "Cow-1") + newUnit("Cow-1", "Cow Group") + mgr:_activateMissionCows() + assert.is_false(groups["Cow Group"].activated) -- the flight is not there yet + newGroup("Gunfighter", 7, "Gunfighter-1") + newUnit("Gunfighter-1", "Gunfighter") + mgr:onBirth({ initiator = units["Gunfighter-1"] }) + runScheduled() + assert.is_true(groups["Cow Group"].activated) + local cow = mgr:aiCowOfFlight("Gunfighter") + assert.is_not_nil(cow) + assert.equals(7200, cow.stock.fuelKg) -- loadout 1 when the entry names none + assert.equals("Follow", lastTask("Cow Group").id) + end) + + it("does not activate a mission-placed cow whose loadout does not exist", function() + mgr._settings.fatCowAICows = { { group = "Cow Group", escort = "Gunfighter", loadout = 7 } } + newGroup("Cow Group", 50, "Cow-1") + newUnit("Cow-1", "Cow Group") + newGroup("Gunfighter", 7, "Gunfighter-1") + newUnit("Gunfighter-1", "Gunfighter") + mgr:_activateMissionCows() + assert.is_false(groups["Cow Group"].activated) + end) + + it("hands the FARP of a cow gone home to the next one", function() + local leader, cow = flightWithCow() + mgr:onLand({ initiator = leader }) + mgr:onLand({ initiator = units[cow.unitName] }) + runScheduled() + local farp = cow.farpName + mgr:releaseAICow("Gunfighter-1") + mgr:onLand({ initiator = units[cow.unitName] }) -- home: despawned + assert.equals(farp, mgr:_farpNameFor("someone else")) + end) + + it("gives a mission-placed cow the loadout its entry names", function() + mgr._settings.fatCowAICows = { { group = "Cow Group", escort = "Gunfighter", loadout = 2 } } + newGroup("Cow Group", 50, "Cow-1") + newUnit("Cow-1", "Cow Group") + newGroup("Gunfighter", 7, "Gunfighter-1") + newUnit("Gunfighter-1", "Gunfighter") + mgr:_activateMissionCows() + runScheduled() + assert.equals(20, mgr:aiCowOfFlight("Gunfighter").stock.items[RADAR]) + end) +end) diff --git a/tests/ci/unit/fatcow_core_spec.lua b/tests/ci/unit/fatcow_core_spec.lua new file mode 100644 index 00000000..5424b3f9 --- /dev/null +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -0,0 +1,432 @@ +---@diagnostic disable +-- tests/ci/unit/fatcow_core_spec.lua +-- FEAT-FAT-COW ticket 02: a cow's stock, written into its FARP warehouse on deploy, read back on pack. +-- ============================================================ + +-- A warehouse double holding liquids (0..3) and items by name, as DCS's Warehouse does. +local function newWarehouse() + local w = { liquids = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 }, items = {} } + function w:getLiquidAmount(t) return self.liquids[t] end + function w:setLiquidAmount(t, v) self.liquids[t] = v end + function w:getItemCount(n) return self.items[n] or 0 end + function w:setItem(n, v) self.items[n] = v end + function w:getInventory() + local weapon = {} + for n, v in pairs(self.items) do if v > 0 then weapon[n] = v end end + return { weapon = weapon } + end + return w +end + +local HELLFIRE = "weapons.missiles.AGM_114K" + +describe("CTLDFatCow stock", function() + + it("is empty only when it holds no fuel and no item", function() + assert.is_true(CTLDFatCow:new("cow", 2, { fuelKg = 0, items = {} }):isEmpty()) + assert.is_false(CTLDFatCow:new("cow", 2, { fuelKg = 1, items = {} }):isEmpty()) + assert.is_false(CTLDFatCow:new("cow", 2, { fuelKg = 0, items = { [HELLFIRE] = 1 } }):isEmpty()) + assert.is_true(CTLDFatCow:new("cow", 2, { fuelKg = 0, items = { [HELLFIRE] = 0 } }):isEmpty()) + end) + + it("copies the stock it is given, so the caller's table is never changed", function() + local given = { fuelKg = 100, items = { [HELLFIRE] = 6 } } + local cow = CTLDFatCow:new("cow", 2, given) + cow.stock.fuelKg = 0 + cow.stock.items[HELLFIRE] = 0 + assert.equals(100, given.fuelKg) + assert.equals(6, given.items[HELLFIRE]) + end) + + it("writes jet fuel and the stocked items, and zeroes the other liquids", function() + local w = newWarehouse() + w.liquids[1], w.liquids[3] = 50, 70 + CTLDFatCow:new("cow", 2, { fuelKg = 7200, items = { [HELLFIRE] = 6 } }):writeWarehouse(w) + assert.equals(7200, w.liquids[0]) + assert.equals(0, w.liquids[1]) + assert.equals(0, w.liquids[2]) + assert.equals(0, w.liquids[3]) + assert.equals(6, w.items[HELLFIRE]) + end) + + it("reads back what the players left: fuel and items consumed are gone", function() + local w = newWarehouse() + local cow = CTLDFatCow:new("cow", 2, { fuelKg = 7200, items = { [HELLFIRE] = 6 } }) + cow:writeWarehouse(w) + w.liquids[0] = 6908 -- an Apache took 292 kg + w.items[HELLFIRE] = 2 -- and 4 Hellfires + cow:readWarehouse(w) + assert.equals(6908, cow.stock.fuelKg) + assert.equals(2, cow.stock.items[HELLFIRE]) + end) + + it("reads an emptied warehouse back as an empty stock", function() + local w = newWarehouse() + local cow = CTLDFatCow:new("cow", 2, { fuelKg = 300, items = { [HELLFIRE] = 2 } }) + cow:writeWarehouse(w) + w.liquids[0] = 0 + w.items[HELLFIRE] = 0 + cow:readWarehouse(w) + assert.is_true(cow:isEmpty()) + end) + + it("re-syncs by writing back what the warehouse holds, not the stock it started with", function() + local w = newWarehouse() + local cow = CTLDFatCow:new("cow", 2, { fuelKg = 7200, items = { [HELLFIRE] = 6 } }) + cow:writeWarehouse(w) + w.liquids[0] = 5000 + w.items[HELLFIRE] = 1 + local writes = 0 + local set = w.setLiquidAmount + w.setLiquidAmount = function(self, t, v) writes = writes + 1; return set(self, t, v) end + cow:resyncWarehouse(w) + assert.equals(5000, w.liquids[0]) + assert.equals(1, w.items[HELLFIRE]) + assert.equals(5000, cow.stock.fuelKg) + assert.is_true(writes > 0) + end) + + it("keeps its own count when someone else refills the warehouse, and puts it back", function() + local w = newWarehouse() + local cow = CTLDFatCow:new("cow", 2, { fuelKg = 4800, items = { [HELLFIRE] = 6 } }) + cow:writeWarehouse(w) + w.liquids[0] = 4500 -- an Apache took 300 kg + cow:resyncWarehouse(w) -- a player is born: CTLD reads first + w.liquids[0], w.liquids[1] = w.liquids[0] + 50000, 50000 -- then VMCT's GRASS refill + w.items[HELLFIRE], w.items["weapons.nurs.HYDRA_70_M151"] = 5000, 5000 + cow:resyncWarehouse(w) + assert.equals(4500, cow.stock.fuelKg) + assert.equals(6, cow.stock.items[HELLFIRE]) + assert.equals(4500, w.liquids[0]) + assert.equals(0, w.liquids[1]) + assert.equals(6, w.items[HELLFIRE]) + assert.equals(0, w.items["weapons.nurs.HYDRA_70_M151"]) -- not the cow's: removed + end) + + it("empties the warehouse", function() + local w = newWarehouse() + local cow = CTLDFatCow:new("cow", 2, { fuelKg = 4800, items = { [HELLFIRE] = 6 } }) + cow:writeWarehouse(w) + cow:emptyWarehouse(w) + assert.equals(0, w.liquids[0]) + assert.equals(0, w.items[HELLFIRE]) + end) +end) + +-- ── The scene itself ─────────────────────────────────────────────────────────────────────────── + +describe("Fat Cow FARP scene", function() + local ROOT = debug.getinfo(1, "S").source:match("^@(.+)tests[\\/]ci[\\/]unit[\\/]") or "" + local saved + + before_each(function() + if not CTLDSceneManager.getInstance():getModel("Fat Cow FARP") then + dofile(ROOT .. "src/scenes/CTLD_fatCowScene.lua") + end + saved = { spawn = CTLDObjectRegistry.spawnObject, addStatic = coalition.addStaticObject, + laidOut = CTLDFatCowManager.onSceneLaidOut, schedule = ctld.scheduler.schedule } + -- The scene waits between steps: run each delayed step at once. + ctld.scheduler.schedule = function(fn, arg) fn(arg) end + end) + + after_each(function() + CTLDObjectRegistry.spawnObject, coalition.addStaticObject = saved.spawn, saved.addStatic + CTLDFatCowManager.onSceneLaidOut = saved.laidOut + ctld.scheduler.schedule = saved.schedule + end) + + it("lays out a FARP, a fuel depot, dressing and a crew — no vehicle, no tent, no ammo dump", function() + local spawned, statics, laidOut, farpName = {}, {}, nil, nil + local function obj(name) return { getName = function() return name end, isExist = function() return true end } end + CTLDObjectRegistry.spawnObject = function(key, coa, cid, x, z, hdg, overrides) + local types = CTLDTypeCollector.typesOfDescriptor(CTLDObjectRegistry.get(key)) + for _, t in ipairs(types) do spawned[#spawned + 1] = t end + if key == "Invisible_FARP" then farpName = overrides and overrides.name end + return obj((overrides and overrides.name) or (key .. "-1")) + end + coalition.addStaticObject = function(_, sd) statics[#statics + 1] = sd; return obj(sd.name) end + CTLDFatCowManager.onSceneLaidOut = function(_, scene) laidOut = scene end + local unit = { + getName = function() return "Cow-1" end, isExist = function() return true end, + getCoalition = function() return coalition.side.BLUE end, getCountry = function() return 2 end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + getPosition = function() return { x = { x = 1, y = 0, z = 0 }, p = { x = 0, y = 0, z = 0 } } end, + } + local scene = CTLDSceneManager.getInstance():playScene(unit, "Fat Cow FARP", { farpName = "CTLD_FatCow_FARP_1" }) + local all = {} + for _, t in ipairs(spawned) do all[t] = true end + for _, sd in ipairs(statics) do all[sd.type] = true end + assert.is_true(all["Invisible FARP"]) + assert.is_true(all["FARP Fuel Depot"]) + assert.is_true(all["Soldier M4"]) + for _, banned in ipairs({ "M978 HEMTT Tanker", "M 818", "FARP Tent", "FARP Ammo Dump Coating" }) do + assert.is_nil(all[banned]) + end + assert.equals(3, #statics) + for _, sd in ipairs(statics) do + assert.equals("CTLD_", sd.name:sub(1, 5)) -- never registered as a loadable crate + end + assert.is_not_nil(laidOut) + assert.equals("CTLD_FatCow_FARP_1", farpName) + for _, o in ipairs(scene._spawnedObjs) do -- packing never destroys the FARP + assert.is_true(o:getName() ~= "CTLD_FatCow_FARP_1") + end + CTLDSceneManager.getInstance():packScene(scene) + end) + + it("stows the cow again when its fuel depot cannot be added", function() + CTLDObjectRegistry.spawnObject = function(key, coa, cid, x, z, hdg, overrides) + if key == "FatCow_Fuel_Depot" then return nil end + return { getName = function() return (overrides and overrides.name) or key end, isExist = function() return true end } + end + local failed + local savedFailed = CTLDFatCowManager.onSceneFailed + CTLDFatCowManager.onSceneFailed = function(_, scene) failed = scene end + local unit = { + getName = function() return "Cow-1" end, isExist = function() return true end, + getCoalition = function() return coalition.side.BLUE end, getCountry = function() return 2 end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + getPosition = function() return { x = { x = 1, y = 0, z = 0 }, p = { x = 0, y = 0, z = 0 } } end, + } + local scene = CTLDSceneManager.getInstance():playScene(unit, "Fat Cow FARP", { farpName = "CTLD_FatCow_FARP_1" }) + CTLDFatCowManager.onSceneFailed = savedFailed + assert.equals(scene, failed) + assert.is_true(scene._aborted) + end) + + it("stows the cow again when its FARP cannot be added", function() + CTLDObjectRegistry.spawnObject = function() return nil end + local failed + local savedFailed = CTLDFatCowManager.onSceneFailed + CTLDFatCowManager.onSceneFailed = function(_, scene) failed = scene end + local unit = { + getName = function() return "Cow-1" end, isExist = function() return true end, + getCoalition = function() return coalition.side.BLUE end, getCountry = function() return 2 end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + getPosition = function() return { x = { x = 1, y = 0, z = 0 }, p = { x = 0, y = 0, z = 0 } } end, + } + local scene = CTLDSceneManager.getInstance():playScene(unit, "Fat Cow FARP", { farpName = "CTLD_FatCow_FARP_1" }) + CTLDFatCowManager.onSceneFailed = savedFailed + assert.equals(scene, failed) + assert.is_true(scene._aborted) + end) +end) + +-- ── Manager: deploy, pack, re-sync ───────────────────────────────────────────────────────────── + +describe("CTLDFatCowManager deploy / pack", function() + local mgr, sm, w, played, packed + local savedPlay, savedPack, savedAirbase + + local function cowUnit(name) + return { + getName = function() return name end, + isExist = function() return true end, + getCoalition = function() return coalition.side.BLUE end, + getCountry = function() return 2 end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + inAir = function() return false end, + } + end + + before_each(function() + CTLDFatCowManager._instance = nil + mgr = CTLDFatCowManager.getInstance() + sm = CTLDSceneManager.getInstance() + w = newWarehouse() + played, packed = nil, nil + savedPlay, savedPack, savedAirbase = sm.playScene, sm.packScene, Airbase.getByName + -- The scene is laid out at once: its last step hands it to the manager. + sm.playScene = function(_, unit, modelName, params) + played = { unit = unit, model = modelName, params = params } + local scene = { _name = "Fat Cow FARP#1", _params = params } + mgr:onSceneLaidOut(scene) + return scene + end + sm.packScene = function(_, scene) packed = scene; return {} end + Airbase.getByName = function(name) + if name:find("^CTLD_FatCow_FARP_") then return { getWarehouse = function() return w end } end + end + end) + + after_each(function() + sm.playScene, sm.packScene, Airbase.getByName = savedPlay, savedPack, savedAirbase + CTLDFatCowManager._instance = nil + end) + + it("deploys the Fat Cow scene and writes the stock into its FARP warehouse", function() + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 7200, items = { [HELLFIRE] = 6 } }) + assert.is_true(mgr:deploy(unit)) + assert.equals("Fat Cow FARP", played.model) + assert.equals(7200, w.liquids[0]) + assert.equals(6, w.items[HELLFIRE]) + local cow = mgr:getCow("Cow-1") + assert.equals(CTLDFatCow.STATE_DEPLOYED, cow.state) + assert.equals("CTLD_FatCow_FARP_1", cow.farpName) + end) + + it("gives each cow one FARP name, the same at every deployment", function() + local a, b = cowUnit("Cow-1"), cowUnit("Cow-2") + mgr:addCow(a, { fuelKg = 100, items = {} }) + mgr:deploy(a) + mgr:pack("Cow-1") + mgr:addCow(b, { fuelKg = 100, items = {} }) + mgr:deploy(b) + assert.equals("CTLD_FatCow_FARP_2", played.params.farpName) + mgr:addCow(a, { fuelKg = 100, items = {} }) -- a piloted cow is declared again each time + mgr:deploy(a) + assert.equals("CTLD_FatCow_FARP_1", played.params.farpName) + end) + + it("refuses to deploy a cow that is already deployed, or an empty one", function() + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 100, items = {} }) + assert.is_true(mgr:deploy(unit)) + local ok, why = mgr:deploy(unit) + assert.is_false(ok) + assert.equals("deployed", why) + + local empty = cowUnit("Cow-2") + mgr:addCow(empty, { fuelKg = 0, items = {} }) + ok, why = mgr:deploy(empty) + assert.is_false(ok) + assert.equals("empty", why) + end) + + it("refuses to deploy a cow in the air", function() + local unit = cowUnit("Cow-1") + unit.inAir = function() return true end + mgr:addCow(unit, { fuelKg = 100, items = {} }) + local ok, why = mgr:deploy(unit) + assert.is_false(ok) + assert.equals("airborne", why) + end) + + it("packs: reads what is left back into the stock and removes the scene", function() + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 7200, items = { [HELLFIRE] = 6 } }) + mgr:deploy(unit) + w.liquids[0] = 6908 + w.items[HELLFIRE] = 2 + assert.is_true(mgr:pack("Cow-1")) + assert.is_not_nil(packed) + local cow = mgr:getCow("Cow-1") + assert.equals(CTLDFatCow.STATE_STOWED, cow.state) + assert.equals(6908, cow.stock.fuelKg) + assert.equals(2, cow.stock.items[HELLFIRE]) + assert.is_nil(cow.scene) + assert.equals(0, w.liquids[0]) -- the FARP stays, emptied + assert.equals(0, w.items[HELLFIRE]) + end) + + it("packing while the scene is still laying out stops it", function() + local scene = { _name = "Fat Cow FARP#1", aborted = false } + function scene:abort() self.aborted = true end + sm.playScene = function(_, _, _, params) scene._params = params; return scene end -- not laid out yet + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 100, items = {} }) + mgr:deploy(unit) + assert.equals(CTLDFatCow.STATE_DEPLOYING, mgr:getCow("Cow-1").state) + assert.is_true(mgr:pack("Cow-1")) + assert.is_true(scene.aborted) + assert.equals(scene, packed) + end) + + it("packing a stowed cow does nothing", function() + mgr:addCow(cowUnit("Cow-1"), { fuelKg = 100, items = {} }) + assert.is_false(mgr:pack("Cow-1")) + assert.is_nil(packed) + end) + + it("re-syncs every deployed cow's warehouse when a player is born", function() + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 7200, items = { [HELLFIRE] = 6 } }) + mgr:deploy(unit) + w.liquids[0] = 4000 + local player = { getPlayerName = function() return "Zip" end } + local later = {} + local savedSchedule = ctld.scheduler.schedule + ctld.scheduler.schedule = function(fn) later[#later + 1] = fn end + mgr:onBirth({ initiator = player }) + ctld.scheduler.schedule = savedSchedule + assert.equals(4000, mgr:getCow("Cow-1").stock.fuelKg) + assert.equals(4000, w.liquids[0]) + w.liquids[0] = 54000 -- a refill run after CTLD on the same birth + assert.equals(1, #later) + later[1]() + assert.equals(4000, w.liquids[0]) + end) + + it("empties, without reading it, the FARP of a cow packed while still laying out", function() + sm.playScene = function(_, _, _, params) + local scene = { _name = "Fat Cow FARP#1", _params = params, abort = function() end } + return scene -- step 1 done, the warehouse not written yet + end + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 100, items = { [HELLFIRE] = 6 } }) + mgr:deploy(unit) + w.liquids[0], w.items["weapons.nurs.HYDRA_70_M151"] = 50000, 5000 -- whatever a new FARP holds + mgr:pack("Cow-1") + assert.equals(0, w.liquids[0]) + assert.equals(0, w.items["weapons.nurs.HYDRA_70_M151"]) + assert.equals(100, mgr:getCow("Cow-1").stock.fuelKg) -- not read back as empty + end) + + it("reports a deployment whose FARP could not be added as failed", function() + sm.playScene = function(_, _, _, params) + local scene = { _name = "Fat Cow FARP#1", _params = params } + mgr:onSceneFailed(scene) -- what the scene's first step does + return scene + end + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 100, items = {} }) + local ok, why = mgr:deploy(unit) + assert.is_false(ok) + assert.equals("failed", why) + assert.equals(CTLDFatCow.STATE_STOWED, mgr:getCow("Cow-1").state) + assert.is_nil(mgr:getCow("Cow-1").scene) + end) + + it("empties the FARP of a packed cow when it is re-synced, and refills nothing", function() + local farps = { CTLD_FatCow_FARP_1 = newWarehouse(), CTLD_FatCow_FARP_2 = newWarehouse() } + Airbase.getByName = function(n) local wh = farps[n]; return wh and { getWarehouse = function() return wh end } end + local a, b = cowUnit("Cow-1"), cowUnit("Cow-2") + mgr:addCow(a, { fuelKg = 100, items = {} }) + mgr:deploy(a) + mgr:pack("Cow-1") + mgr:addCow(b, { fuelKg = 200, items = {} }) + mgr:deploy(b) + farps.CTLD_FatCow_FARP_1.liquids[0] = 50000 -- a refill of every FARP + farps.CTLD_FatCow_FARP_2.liquids[0] = 50000 + mgr:_resyncFarps() + assert.equals(0, farps.CTLD_FatCow_FARP_1.liquids[0]) + assert.equals(200, farps.CTLD_FatCow_FARP_2.liquids[0]) + end) + + it("closes, emptied, the FARP a piloted cow's pilot left open when a new pilot takes the slot", function() + local unit = cowUnit("Cow-1") + local cow = mgr:addCow(unit, { fuelKg = 2400, items = {} }) + cow.crates = {} -- a piloted cow + mgr:deploy(unit) + local newPilot = cowUnit("Cow-1") + newPilot.getPlayerName = function() return "Griffin" end + local savedName, savedSchedule = ctld.utils.safeObjectName, ctld.scheduler.schedule + ctld.utils.safeObjectName = function(o) return o:getName() end + ctld.scheduler.schedule = function() end + mgr:onBirth({ initiator = newPilot }) + ctld.utils.safeObjectName, ctld.scheduler.schedule = savedName, savedSchedule + assert.is_nil(mgr:getCow("Cow-1")) + assert.is_not_nil(packed) + assert.equals(0, w.liquids[0]) + end) + + it("removing a deployed cow packs it first", function() + local unit = cowUnit("Cow-1") + mgr:addCow(unit, { fuelKg = 100, items = {} }) + mgr:deploy(unit) + mgr:removeCow("Cow-1") + assert.is_not_nil(packed) + assert.is_nil(mgr:getCow("Cow-1")) + end) +end) diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua new file mode 100644 index 00000000..382c6332 --- /dev/null +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -0,0 +1,357 @@ +---@diagnostic disable +-- tests/ci/unit/fatcow_piloted_spec.lua +-- FEAT-FAT-COW ticket 03: a piloted cow's stock is the cow crates in its hold; packing puts what is +-- left back into them. +-- ============================================================ + +local HELLFIRE = "weapons.missiles.AGM_114K" + +local function crate(name, fatCow, weight) + return { crateName = name, descriptor = { weight = weight or 2400, fatCow = fatCow }, metadata = {} } +end + +local function fuelCrate(name) return crate(name, { fuelKg = 2400 }, 2400) end +local function hellfireCrate(name) return crate(name, { item = HELLFIRE, count = 4 }, 250) end + +describe("Fat Cow crates on board", function() + + it("sums the cow crates into one stock", function() + local stock = CTLDFatCow.stockOfCrates({ fuelCrate("a"), fuelCrate("b"), hellfireCrate("h") }) + assert.equals(4800, stock.fuelKg) + assert.equals(4, stock.items[HELLFIRE]) + end) + + it("reads a crate's remaining content once it has served", function() + local a = fuelCrate("a") + a.metadata.fatCow = { fuelKg = 600 } + local stock = CTLDFatCow.stockOfCrates({ a, fuelCrate("b") }) + assert.equals(3000, stock.fuelKg) + end) + + it("ignores crates that are not cow crates", function() + local other = { crateName = "x", descriptor = { weight = 1000.01 }, metadata = {} } + local stock = CTLDFatCow.stockOfCrates({ other, fuelCrate("a") }) + assert.equals(2400, stock.fuelKg) + end) + + it("puts what is left back into the crates, in order, and lists the emptied ones", function() + local a, b, h = fuelCrate("a"), fuelCrate("b"), hellfireCrate("h") + local emptied = CTLDFatCow.refillCrates({ fuelKg = 3000, items = { [HELLFIRE] = 0 } }, { a, b, h }) + assert.equals(2400, a.metadata.fatCow.fuelKg) + assert.equals(600, b.metadata.fatCow.fuelKg) + assert.equals(2400, a.metadata.massKg) + assert.equals(600, b.metadata.massKg) + assert.equals(1, #emptied) + assert.equals("h", emptied[1].crateName) + end) + + it("scales a part-used ammo crate's mass to what it still holds", function() + local h = hellfireCrate("h") + local emptied = CTLDFatCow.refillCrates({ fuelKg = 0, items = { [HELLFIRE] = 1 } }, { h }) + assert.equals(0, #emptied) + assert.equals(1, h.metadata.fatCow.count) + assert.equals(62.5, h.metadata.massKg) + end) + + it("never fills a crate beyond what it held when the FARP opened", function() + local a = fuelCrate("a") + a.metadata.fatCow = { fuelKg = 500 } + CTLDFatCow.refillCrates({ fuelKg = 2000, items = {} }, { a }) + assert.equals(500, a.metadata.fatCow.fuelKg) + end) +end) + +describe("Piloted cow commands", function() + local mgr, cm, sm, w, unit, messages, saved + + before_each(function() + CTLDFatCowManager._instance = nil + mgr = CTLDFatCowManager.getInstance() + mgr._crateDescs = { + { weight = 2400, desc = "Fat Cow fuel tank", unit = "FatCow:fuel", fatCow = { fuelKg = 2400 } }, + } + cm, sm = CTLDCrateManager.getInstance(), CTLDSceneManager.getInstance() + w = { liquids = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 }, items = {} } + function w:getLiquidAmount(t) return self.liquids[t] end + function w:setLiquidAmount(t, v) self.liquids[t] = v end + function w:getItemCount(n) return self.items[n] or 0 end + function w:setItem(n, v) self.items[n] = v end + messages = {} + unit = { + airborne = false, + getName = function() return "Cow-1" end, + isExist = function() return true end, + getCoalition = function() return coalition.side.BLUE end, + getCountry = function() return 2 end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + inAir = function(self) return self.airborne end, + } + saved = { + unitGet = Unit.getByName, inAir = ctld.utils.inAir, gid = ctld.utils.getGroupId, + outGroup = trigger.action.outTextForGroup, crates = cm.crates, play = sm.playScene, + pack = sm.packScene, airbase = Airbase.getByName, weight = ctld.utils.updateTransportWeight, + zones = CTLDZoneManager.getInstance().getLogisticZonesAtPoint, aligned = cm.spawnCratesAligned, + } + Unit.getByName = function(n) if n == "Cow-1" then return unit end end + ctld.utils.inAir = function(u) return u.airborne end + ctld.utils.getGroupId = function() return 7 end + trigger.action.outTextForGroup = function(_, text) messages[#messages + 1] = text end + ctld.utils.updateTransportWeight = function() end + cm.crates = {} + sm.playScene = function(_, u, model, params) + local scene = { _name = "Fat Cow FARP#1", _params = params } + mgr:onSceneLaidOut(scene) + return scene + end + sm.packScene = function() return {} end + Airbase.getByName = function(n) if n == "CTLD_FatCow_FARP_1" then return { getWarehouse = function() return w end } end end + end) + + after_each(function() + Unit.getByName, ctld.utils.inAir, ctld.utils.getGroupId = saved.unitGet, saved.inAir, saved.gid + trigger.action.outTextForGroup, cm.crates = saved.outGroup, saved.crates + sm.playScene, sm.packScene, Airbase.getByName = saved.play, saved.pack, saved.airbase + ctld.utils.updateTransportWeight = saved.weight + CTLDZoneManager.getInstance().getLogisticZonesAtPoint = saved.zones + cm.spawnCratesAligned = saved.aligned + CTLDFatCowManager._instance = nil + end) + + local function loadFuelCrate(name) + local c = CTLDCrate:new({ crateName = name, descriptor = mgr._crateDescs[1] }) + c.state, c.loadedBy = CTLDCrate.STATE.LOADED, unit + cm.crates[name] = c + return c + end + + it("deploys with the cow crates on board as the FARP's stock", function() + loadFuelCrate("a"); loadFuelCrate("b") + mgr:deployFromHold("Cow-1") + assert.equals(4800, w.liquids[0]) + assert.equals(CTLDFatCow.STATE_DEPLOYED, mgr:getCow("Cow-1").state) + end) + + it("refuses to deploy without a cow crate on board, or in the air", function() + mgr:deployFromHold("Cow-1") + assert.is_nil(mgr:getCow("Cow-1")) + loadFuelCrate("a") + unit.airborne = true + mgr:deployFromHold("Cow-1") + assert.is_nil(mgr:getCow("Cow-1")) + assert.equals(2, #messages) + end) + + it("packs what is left into the hold and removes the crates left empty", function() + local a, b = loadFuelCrate("a"), loadFuelCrate("b") + mgr:deployFromHold("Cow-1") + w.liquids[0] = 1000 + mgr:packIntoHold("Cow-1") + assert.is_nil(mgr:getCow("Cow-1")) + assert.equals(1000, a.metadata.fatCow.fuelKg) + assert.is_nil(cm.crates["b"]) + -- Deploying again starts from what is left. + mgr:deployFromHold("Cow-1") + assert.equals(1000, w.liquids[0]) + end) + + it("gives a crate unloaded while the FARP was open its share back", function() + local a, b = loadFuelCrate("a"), loadFuelCrate("b") + mgr:deployFromHold("Cow-1") + b.state, b.loadedBy = CTLDCrate.STATE.LANDED, nil -- unloaded meanwhile + w.liquids[0] = 4800 -- nobody refuelled + mgr:packIntoHold("Cow-1") + assert.equals(2400, a.metadata.fatCow.fuelKg) + assert.equals(2400, b.metadata.fatCow.fuelKg) + end) + + it("packs on take-off", function() + loadFuelCrate("a") + mgr:deployFromHold("Cow-1") + w.liquids[0] = 2000 + unit.airborne = true + mgr:onTakeoff({ initiator = unit }) + assert.is_nil(mgr:getCow("Cow-1")) + assert.equals(2000, cm.crates["a"].metadata.fatCow.fuelKg) + end) + + it("requests a cow crate only on the ground inside a logistic zone", function() + local spawned = 0 + cm.spawnCratesAligned = function() spawned = spawned + 1; return 1 end + local inZone = false + CTLDZoneManager.getInstance().getLogisticZonesAtPoint = function() + return inZone and { {} } or {} + end + mgr:requestCrate("Cow-1", 2400) + assert.equals(0, spawned) + inZone = true + unit.airborne = true + mgr:requestCrate("Cow-1", 2400) + assert.equals(0, spawned) + unit.airborne = false + mgr:requestCrate("Cow-1", 2400) + assert.equals(1, spawned) + end) +end) + +describe("Fat Cow loadouts", function() + local mgr, cm, unit, messages, saved + + before_each(function() + CTLDFatCowManager._instance = nil + mgr, cm, messages = CTLDFatCowManager.getInstance(), CTLDCrateManager.getInstance(), {} + local zm = CTLDZoneManager.getInstance() + saved = { unitGet = Unit.getByName, inAir = ctld.utils.inAir, gid = ctld.utils.getGroupId, + outGroup = trigger.action.outTextForGroup, crates = cm.crates, index = cm._weightIndex, + weight = ctld.utils.updateTransportWeight, zones = zm.getLogisticZonesAtPoint } + cm.crates = {} + cm._weightIndex = {} + unit = { + airborne = false, + getName = function() return "Cow-1" end, isExist = function() return true end, + getTypeName = function() return "CH-47Fbl1" end, + getCoalition = function() return coalition.side.BLUE end, getCountry = function() return 2 end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + inAir = function(self) return self.airborne end, + } + Unit.getByName = function(n) if n == "Cow-1" then return unit end end + ctld.utils.inAir = function(u) return u.airborne end + ctld.utils.getGroupId = function() return 7 end + ctld.utils.updateTransportWeight = function() end + trigger.action.outTextForGroup = function(_, text) messages[#messages + 1] = text end + zm.getLogisticZonesAtPoint = function() return { {} } end + mgr:_injectCrateDescriptors() + end) + + after_each(function() + local zm = CTLDZoneManager.getInstance() + Unit.getByName, ctld.utils.inAir, ctld.utils.getGroupId = saved.unitGet, saved.inAir, saved.gid + trigger.action.outTextForGroup, cm.crates, cm._weightIndex = saved.outGroup, saved.crates, saved.index + ctld.utils.updateTransportWeight, zm.getLogisticZonesAtPoint = saved.weight, saved.zones + CTLDFatCowManager._instance = nil + end) + + -- The default loadouts: 1 = 40 Hellfires (25 laser, 15 radar) + 2400 rounds, 2 = 60 (38 + 22) + 1200, + -- 3 = 40 laser + 2400, 4 = 60 laser + 1200; all with 4800 kg of fuel. + local RADAR, ROUNDS = "weapons.missiles.AGM_114", "weapons.shells.M230_HEDP M789" + + it("splits a loadout into crates, the last of each kind part-full, straight into the hold", function() + mgr:loadLoadout("Cow-1", 1) + local onboard = cm:cratesOnboard("Cow-1") + assert.equals(7, #onboard) -- 2 tanks, 20 + 5 laser, 15 radar, 2 x 1200 rounds + local stock = CTLDFatCow.stockOfCrates(onboard) + assert.equals(4800, stock.fuelKg) + assert.equals(25, stock.items[HELLFIRE]) + assert.equals(15, stock.items[RADAR]) + assert.equals(2400, stock.items[ROUNDS]) + local mass = 0 + for _, c in ipairs(onboard) do + assert.is_nil(c.dcsStatic) -- nothing left on the ground + mass = mass + (c.metadata.massKg or c.descriptor.weight) + end + assert.is_true(math.abs(mass - 7867.5) < 0.5) -- 4800 + 25 x 45.3 + 15 x 49 + 2400 x 0.5 + end) + + it("fits every default loadout into the CH-47F's hold, under what it lifts with 80 % internal fuel", function() + local caps = ctld.gs("capabilitiesByType")["CH-47Fbl1"] + for i = 1, #ctld.gs("fatCowLoadouts") do + local crates = mgr:cratesForLoadout(ctld.gs("fatCowLoadouts")[i]) + local mass = 0 + for _, c in ipairs(crates) do mass = mass + c.desc.weight * c.fraction end + assert.is_true(#crates <= caps.maxCratesOnboard) + assert.is_true(mass <= 22680 - 11400 - 2444) -- db.Units.lua: M_max, M_empty, 80 % of M_fuel_max + end + end) + + it("lists the Apache loadouts at the top, the Russian and mixed ones in their submenus", function() + local subs, cmds = {}, {} + local menu = { + addSubMenu = function(_, path, name) subs[#subs + 1] = table.concat(path, "/") .. "/" .. name end, + addCommand = function(_, path, label, fn, arg) cmds[#cmds + 1] = { path = table.concat(path, "/"), label = label, fn = fn, arg = arg } end, + } + mgr:_addLoadoutCommands(menu, { "CTLD", "Fat Cow", "Load" }, "Cow-1", "loadLoadout") + assert.same({ "CTLD/Fat Cow/Load/Russian", "CTLD/Fat Cow/Load/Mixed" }, subs) + local where = {} + for _, c in ipairs(cmds) do where[c.path] = (where[c.path] or 0) + 1 end + assert.equals(4, where["CTLD/Fat Cow/Load"]) + assert.equals(4, where["CTLD/Fat Cow/Load/Russian"]) + assert.equals(2, where["CTLD/Fat Cow/Load/Mixed"]) + local called + local savedLoad = mgr.loadLoadout + mgr.loadLoadout = function(_, unitName, index) called = { unitName, index } end + cmds[5].fn(cmds[5].arg) + mgr.loadLoadout = savedLoad + assert.same({ "Cow-1", 5 }, called) + end) + + it("finds the crates by what they carry, whatever their translated name", function() + mgr._crateDescs = { + { weight = 2400, desc = "Réservoir Fat Cow (800 gal)", fatCow = { fuelKg = 2400 } }, + { weight = 906, desc = "Hellfire laser Fat Cow (x20)", fatCow = { item = HELLFIRE, count = 20 } }, + } + local crates = mgr:cratesForLoadout({ fuelKg = 3000, items = { { item = HELLFIRE, count = 20 } } }) + assert.equals(3, #crates) + assert.equals(600, crates[2].content.fuelKg) + assert.equals(0.25, crates[2].fraction) + local none, missing = mgr:cratesForLoadout({ fuelKg = 0, items = { { item = RADAR, count = 1 } } }) + assert.is_nil(none) + assert.equals(RADAR, missing) + end) + + it("refuses a loadout the hold has no room for, and outside a logistic zone", function() + mgr:loadLoadout("Cow-1", 1) + mgr:loadLoadout("Cow-1", 3) -- 7 + 6 > 8 + assert.equals(7, #cm:cratesOnboard("Cow-1")) + CTLDZoneManager.getInstance().getLogisticZonesAtPoint = function() return {} end + mgr:unloadHold("Cow-1") + assert.equals(7, #cm:cratesOnboard("Cow-1")) + end) + + it("unloads the cow crates in a logistic zone", function() + mgr:loadLoadout("Cow-1", 4) + mgr:unloadHold("Cow-1") + assert.equals(0, #cm:cratesOnboard("Cow-1")) + end) +end) + +describe("CTLDCrateManager:getLoadedCrateWeight with a part-used crate", function() + local cm, saved + + before_each(function() + cm = CTLDCrateManager.getInstance() + saved = cm.crates + cm.crates = {} + end) + + after_each(function() + cm.crates = saved + end) + + it("counts a crate's metadata mass when it has one, its descriptor weight otherwise", function() + local function loaded(name, weight, massKg) + local c = CTLDCrate:new({ crateName = name, descriptor = { weight = weight } }) + c.state = CTLDCrate.STATE.LOADED + c.loadedBy = { getName = function() return "Cow-1" end } + c.metadata.massKg = massKg + cm.crates[name] = c + end + loaded("a", 2400, 600) + loaded("b", 250, nil) + assert.equals(850, cm:getLoadedCrateWeight("Cow-1")) + end) + + it("indexes an outside descriptor on the first free weight", function() + local savedIndex = cm._weightIndex + cm._weightIndex = { [2400] = { unit = "other" } } + local d = cm:registerDescriptor({ weight = 2400, desc = "Fat Cow fuel tank" }) + assert.equals(2400.01, d.weight) + assert.equals(d, cm._weightIndex[2400.01]) + cm._weightIndex = savedIndex + end) + + it("never offers a cow crate to Unpack", function() + local c = CTLDCrate:new({ crateName = "a", descriptor = { weight = 2400, fatCow = { fuelKg = 2400 } } }) + assert.is_false(c.canBeUnpacked) + local v = CTLDCrate:new({ crateName = "v", descriptor = { weight = 1000.01 } }) + assert.is_true(v.canBeUnpacked) + end) +end) diff --git a/tests/ci/unit/scene_asset_gate_spec.lua b/tests/ci/unit/scene_asset_gate_spec.lua index 6dc7ec5a..30e12365 100644 --- a/tests/ci/unit/scene_asset_gate_spec.lua +++ b/tests/ci/unit/scene_asset_gate_spec.lua @@ -29,7 +29,7 @@ local ROOT = debug.getinfo(1, "S").source:match("^@(.+)tests[\\/]ci[\\/]unit[\\/ local SCENE_FILES = { "CTLD_farpScene", "CTLD_fobScene", "CTLD_mineFieldScene", - "CTLD_countrysideFarpScene", "CTLD_farpAlphaScene", + "CTLD_countrysideFarpScene", "CTLD_farpAlphaScene", "CTLD_fatCowScene", } -- DCS type names a registry descriptor spawns — via the shared collector, which handles both diff --git a/tests/dcs/pilotActive/scenario_fatcow_spike.lua b/tests/dcs/pilotActive/scenario_fatcow_spike.lua new file mode 100644 index 00000000..dc7b4484 --- /dev/null +++ b/tests/dcs/pilotActive/scenario_fatcow_spike.lua @@ -0,0 +1,323 @@ +---@diagnostic disable +-- @tier: ia (fly) -- a player lands, opens the ground crew window and reads what it offers +-- ============================================================================= +-- tests/dcs/pilotActive/scenario_fatcow_spike.lua +-- FEAT-FAT-COW ticket 01 — in-game spike, before any Fat Cow code is written. +-- +-- Pure DCS API, no CTLD: it measures what DCS does, not what CTLD does. +-- Embedded in missions/Test_FatCow_Spike.miz (mission start trigger), or injected +-- over dcs-bridge in any mission with a player helicopter. +-- +-- Questions (see .backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md): +-- (a) does a FARP spawned mid-mission refuel/rearm a player already in game? +-- (b) is a finite warehouse stock respected, and seen identically by the server +-- and by a client (rearm window)? +-- (c) does an Invisible FARP need a fuel/ammo vehicle in range? +-- +-- Each player group gets an F10 menu "Fat Cow spike": +-- 1. Invisible FARP under me — spawns an Invisible FARP at the caller's position +-- 2. Finite stock (300 kg, 2 HF) — sets the FARP warehouse to a small known stock +-- 3. Read stock + my aircraft — prints the warehouse and the caller's fuel/ammo +-- 4. Add fuel + ammo trucks — spawns an M978 and an M 818 30 m from the FARP +-- 5. Crates only (barrels + ammo) — barrels_cargo and ammo_cargo, no depot, no vehicle +-- 6. Remove everything — destroys what the spike spawned +-- 7. Add FARP props, no vehicle — fuel depot, ammo dump, barrels, ammo crate: do statics supply? +-- 8. Fuel depot only — FARP Fuel Depot alone +-- 9. Ammo dump only — FARP Ammo Dump Coating alone +-- +-- Every result line goes to the screen (60 s) and to dcs.log, prefixed [FCSPIKE]. +-- _SCN_FCSPIKE_RESULT holds the lines measured so far. +-- ============================================================================= + +if FCSPIKE and FCSPIKE.loaded then + trigger.action.outText("[FCSPIKE] already loaded", 10) + return _SCN_FCSPIKE_RESULT +end + +FCSPIKE = { loaded = true } +local S = FCSPIKE + +local TAG = "[FCSPIKE]" +local MENU_NAME = "Fat Cow spike" +local STOCK_FUEL_KG = 300 -- below what a 30 % Apache takes to fill up, so a cap shows +local STOCK_HELLFIRE = 2 +-- Warehouse item names tried for the Hellfire; the readout says which ones DCS accepts. +local HELLFIRE_ITEMS = { + "weapons.missiles.AGM_114K", + "weapons.missiles.AGM_114L", + "weapons.missiles.AGM_114", +} +local LIQUID_NAMES = { [0] = "jet fuel", [1] = "avgas", [2] = "MW50", [3] = "diesel" } + +S.objects = {} -- statics spawned (names) +S.groups = {} -- ground groups spawned (names) +S.farpName = nil +S.counter = 0 +S.lines = {} +_SCN_FCSPIKE_RESULT = "[FCSPIKE] STARTED" + +local function say(msg) + local line = TAG .. " " .. msg + trigger.action.outText(line, 60) + env.info(line) + S.lines[#S.lines + 1] = msg + _SCN_FCSPIKE_RESULT = "[FCSPIKE] RUNNING: " .. table.concat(S.lines, " | ") +end + +local function nextName(prefix) + S.counter = S.counter + 1 + return "FCSPIKE_" .. prefix .. "_" .. S.counter +end + +local function callerUnit(groupName) + local g = Group.getByName(groupName) + if not g then return nil end + local u = g:getUnit(1) + if u and u:isExist() then return u end + return nil +end + +local function headingOf(unit) + local p = unit:getPosition() + local h = math.atan2(p.x.z, p.x.x) + if h < 0 then h = h + 2 * math.pi end + return h +end + +local function smoke(x, z) + trigger.action.smoke({ x = x, y = land.getHeight({ x = x, y = z }), z = z }, trigger.smokeColor.Green) +end + +-- ----------------------------------------------------------------------------- +-- Readouts +-- ----------------------------------------------------------------------------- + +local function warehouseOf(farpName) + if not farpName then return nil, "no FARP spawned yet (command 1 or 5 first)" end + local ok, ab = pcall(Airbase.getByName, farpName) + if not ok or not ab then return nil, "Airbase.getByName('" .. farpName .. "') = nil" end + local okW, w = pcall(ab.getWarehouse, ab) + if not okW then return nil, "getWarehouse() raised: " .. tostring(w) end + if not w then return nil, "getWarehouse() = nil" end + return w, nil +end + +local function readWarehouse(label) + local w, err = warehouseOf(S.farpName) + if not w then + say(label .. ": " .. err) + return + end + local parts = {} + for i = 0, 3 do + local ok, v = pcall(w.getLiquidAmount, w, i) + parts[#parts + 1] = LIQUID_NAMES[i] .. "=" .. (ok and tostring(v) or ("ERR " .. tostring(v))) + end + for _, item in ipairs(HELLFIRE_ITEMS) do + local ok, v = pcall(w.getItemCount, w, item) + parts[#parts + 1] = item:gsub("weapons.missiles.", "") .. "=" .. (ok and tostring(v) or "ERR") + end + local okI, inv = pcall(w.getInventory, w) + if okI and inv then + local nW, nA = 0, 0 + for _ in pairs(inv.weapon or {}) do nW = nW + 1 end + for _ in pairs(inv.aircraft or {}) do nA = nA + 1 end + parts[#parts + 1] = "inventory: " .. nW .. " weapon types, " .. nA .. " aircraft types" + else + parts[#parts + 1] = "getInventory() unavailable" + end + say(label .. " [" .. S.farpName .. "] " .. table.concat(parts, ", ")) +end + +local function readUnit(unit) + local desc = unit:getDesc() + local fuelKg = math.floor(unit:getFuel() * (desc.fuelMassMax or 0) + 0.5) + local ammo = {} + for _, a in ipairs(unit:getAmmo() or {}) do + local tn = a.desc and a.desc.typeName or "?" + ammo[#ammo + 1] = tn .. " x" .. tostring(a.count) + end + if #ammo == 0 then ammo[1] = "none" end + say("AIRCRAFT " .. unit:getName() .. " (" .. desc.typeName .. "): fuel " .. fuelKg .. " kg / " + .. tostring(desc.fuelMassMax) .. " kg, ammo: " .. table.concat(ammo, ", ")) +end + +-- ----------------------------------------------------------------------------- +-- Commands +-- ----------------------------------------------------------------------------- + +local function spawnFarp(groupName, variant) + local unit = callerUnit(groupName) + if not unit then say("no unit for group " .. groupName) return end + local p = unit:getPoint() + local hdg = headingOf(unit) + local x, z = p.x, p.z + local sd + if variant == "single" then + x = p.x + math.cos(hdg) * 40 + z = p.z + math.sin(hdg) * 40 + -- Same type and shape as CTLD's SINGLE_HELIPAD registry entry (farpAlphaScene). + sd = { type = "SINGLE_HELIPAD", shape_name = "FARP", category = "Heliports" } + else + sd = { type = "Invisible FARP", shape_name = "invisiblefarp", category = "Heliports" } + end + sd.name = nextName(variant == "single" and "PAD" or "FARP") + sd.x = x + sd.y = z + sd.heading = hdg + sd.dead = false + sd.heliport_frequency = "127.5" + sd.heliport_callsign_id = 1 + sd.heliport_modulation = 0 + local ok, obj = pcall(coalition.addStaticObject, unit:getCountry(), sd) + if not ok or not obj then + say("SPAWN " .. sd.type .. " FAILED: " .. tostring(obj)) + return + end + S.objects[#S.objects + 1] = sd.name + S.farpName = sd.name + smoke(x, z) + say("SPAWNED " .. sd.type .. " '" .. sd.name .. "' at t=" .. math.floor(timer.getTime()) + .. " s (green smoke). No vehicle around it.") + timer.scheduleFunction(function() + readWarehouse("INITIAL STOCK (2 s after spawn)") + return nil + end, nil, timer.getTime() + 2) +end + +local function setFiniteStock() + local w, err = warehouseOf(S.farpName) + if not w then say("SET STOCK: " .. err) return end + pcall(w.setLiquidAmount, w, 0, STOCK_FUEL_KG) + for i = 1, 3 do pcall(w.setLiquidAmount, w, i, 0) end + for i, item in ipairs(HELLFIRE_ITEMS) do + pcall(w.setItem, w, item, i == 1 and STOCK_HELLFIRE or 0) + end + say("SET STOCK requested: jet fuel " .. STOCK_FUEL_KG .. " kg, AGM_114K " .. STOCK_HELLFIRE + .. ", other Hellfire names 0") + readWarehouse("STOCK READ BACK") +end + +local function readAll(groupName) + readWarehouse("STOCK NOW") + local unit = callerUnit(groupName) + if unit then readUnit(unit) end +end + +local function farpPoint(unit) + local farp = S.farpName and StaticObject.getByName(S.farpName) + return (farp and farp:isExist()) and farp:getPoint() or unit:getPoint() +end + +-- fuelOnly: the tanker alone, to see whether one vehicle serves weapons as well. +local function addTrucks(groupName, fuelOnly) + local unit = callerUnit(groupName) + if not unit then say("no unit for group " .. groupName) return end + local p = farpPoint(unit) + local gName = nextName("TRUCKS") + local units = { + { name = gName .. "_fuel", type = "M978 HEMTT Tanker", x = p.x + 30, y = p.z, heading = 0, skill = "Average" }, + } + if not fuelOnly then + units[2] = { name = gName .. "_ammo", type = "M 818", x = p.x + 30, y = p.z + 10, heading = 0, skill = "Average" } + end + local data = { name = gName, task = "Ground Nothing", units = units } + local ok, g = pcall(coalition.addGroup, unit:getCountry(), Group.Category.GROUND, data) + if not ok or not g then say("TRUCKS FAILED: " .. tostring(g)) return end + S.groups[#S.groups + 1] = gName + say("TRUCKS spawned 30 m north of the FARP: M978 HEMTT Tanker" .. (fuelOnly and " only" or " + M 818")) +end + +-- Statics only, no vehicle: which FARP props count as suppliers? Shapes from DCS's db.Units.lua. +local PROPS = { + depot = { type = "FARP Fuel Depot", shape_name = "GSM Rus", category = "Fortifications", dx = 30, dz = 0 }, + dump = { type = "FARP Ammo Dump Coating", shape_name = "SetkaKP", category = "Fortifications", dx = 30, dz = 15 }, + barrels = { type = "barrels_cargo", shape_name = "barrels_cargo", category = "Cargos", dx = 25, dz = -10 }, + crate = { type = "ammo_cargo", shape_name = "ammo_box_cargo", category = "Cargos", dx = 25, dz = 25 }, +} +local PROP_SETS = { + all = { "depot", "dump", "barrels", "crate" }, + crates = { "barrels", "crate" }, + depot = { "depot" }, + dump = { "dump" }, +} + +local function addProps(groupName, set) + local unit = callerUnit(groupName) + if not unit then say("no unit for group " .. groupName) return end + local p = farpPoint(unit) + local made = {} + for _, key in ipairs(PROP_SETS[set or "all"]) do + local d = PROPS[key] + local sd = { + name = nextName("PROP"), type = d.type, shape_name = d.shape_name, category = d.category, + x = p.x + d.dx, y = p.z + d.dz, heading = 0, dead = false, + } + local ok, obj = pcall(coalition.addStaticObject, unit:getCountry(), sd) + if ok and obj then + S.objects[#S.objects + 1] = sd.name + made[#made + 1] = d.type + else + made[#made + 1] = d.type .. " FAILED" + end + end + say("PROPS spawned around the FARP, no vehicle: " .. table.concat(made, ", ")) +end + +local function removeAll() + for _, n in ipairs(S.objects) do + local o = StaticObject.getByName(n) + if o and o:isExist() then o:destroy() end + end + for _, n in ipairs(S.groups) do + local g = Group.getByName(n) + if g and g:isExist() then g:destroy() end + end + S.objects, S.groups, S.farpName = {}, {}, nil + say("REMOVED everything the spike spawned") +end + +-- ----------------------------------------------------------------------------- +-- Menus: one per player group, rebuilt on every birth +-- ----------------------------------------------------------------------------- + +local function buildMenu(unit) + local g = unit:getGroup() + if not g then return end + local gid, gName = g:getID(), g:getName() + missionCommands.removeItemForGroup(gid, { MENU_NAME }) + local root = missionCommands.addSubMenuForGroup(gid, MENU_NAME) + missionCommands.addCommandForGroup(gid, "1. Invisible FARP under me", root, spawnFarp, gName) + missionCommands.addCommandForGroup(gid, "2. Finite stock (300 kg, 2 Hellfire)", root, setFiniteStock) + missionCommands.addCommandForGroup(gid, "3. Read stock + my aircraft", root, readAll, gName) + missionCommands.addCommandForGroup(gid, "4. Add fuel + ammo trucks", root, addTrucks, gName) + missionCommands.addCommandForGroup(gid, "5. Crates only (barrels + ammo)", root, + function(n) addProps(n, "crates") end, gName) + missionCommands.addCommandForGroup(gid, "6. Remove everything", root, removeAll) + missionCommands.addCommandForGroup(gid, "7. Add FARP props, no vehicle", root, addProps, gName) + missionCommands.addCommandForGroup(gid, "8. Fuel depot only", root, + function(n) addProps(n, "depot") end, gName) + missionCommands.addCommandForGroup(gid, "9. Ammo dump only", root, + function(n) addProps(n, "dump") end, gName) +end + +S.handler = {} +function S.handler:onEvent(e) + if e.id ~= world.event.S_EVENT_BIRTH or not e.initiator then return end + local ok, pn = pcall(function() return e.initiator:getPlayerName() end) + if ok and pn then + buildMenu(e.initiator) + say("BIRTH of " .. pn .. " in " .. e.initiator:getName() .. " at t=" .. math.floor(timer.getTime()) + .. " s — FARP present: " .. tostring(S.farpName)) + if S.farpName then readWarehouse("STOCK AT BIRTH") end + end +end +world.addEventHandler(S.handler) + +for _, side in ipairs({ coalition.side.BLUE, coalition.side.RED }) do + for _, u in ipairs(coalition.getPlayers(side) or {}) do buildMenu(u) end +end + +trigger.action.outText(TAG .. " loaded — F10 > Other > " .. MENU_NAME, 20) +env.info(TAG .. " loaded") +return _SCN_FCSPIKE_RESULT diff --git a/tools/build/listToMerge.txt b/tools/build/listToMerge.txt index 4ee5c13c..e6fa8931 100644 --- a/tools/build/listToMerge.txt +++ b/tools/build/listToMerge.txt @@ -25,12 +25,14 @@ CTLD_beacon.lua CTLD_recon.lua CTLD_jtac.lua CTLD_player.lua +CTLD_fatCow.lua -- Scene data (after all managers defined, before CTLDCoreManager init so model.crate auto-injection runs correctly) scenes/CTLD_farpScene.lua scenes/CTLD_fobScene.lua scenes/CTLD_mineFieldScene.lua scenes/CTLD_countrysideFarpScene.lua scenes/CTLD_farpAlphaScene.lua +scenes/CTLD_fatCowScene.lua -- Orchestrator (instantiates all managers, including CTLDCrateManager._processSpawnableCrates) CTLD_core.lua -- Legacy API compatibility (after all managers) diff --git a/tools/ctld-tools/tests/test_catalog.py b/tools/ctld-tools/tests/test_catalog.py index 22dbba7e..187c028c 100644 --- a/tools/ctld-tools/tests/test_catalog.py +++ b/tools/ctld-tools/tests/test_catalog.py @@ -93,7 +93,7 @@ def test_round_trip_preserves_values(): def test_loads_the_real_catalogue(): c = Catalog.load(SRC_YAML) assert c.get("numberOfTroops") == 10 - assert str(c.get("configVersion")) == "2.2.0" + assert str(c.get("configVersion")) == "2.3.0" assert "SAM mid range" in c.get("spawnableCrates") # AA baked in (lot 1 t05) # full round-trip: dump then reload equals the same settings again = Catalog.loads(c.dumps()) diff --git a/tools/ctld-tools/tests/test_completion_entries_web.py b/tools/ctld-tools/tests/test_completion_entries_web.py index 092ffbb3..81e641e1 100644 --- a/tools/ctld-tools/tests/test_completion_entries_web.py +++ b/tools/ctld-tools/tests/test_completion_entries_web.py @@ -46,8 +46,8 @@ def _open(path: Path) -> dict: return resp.json() -def test_the_catalogue_version_is_2_2_0(): - assert str(_default().get("configVersion")) == "2.2.0" +def test_the_catalogue_version_is_2_3_0(): + assert str(_default().get("configVersion")) == "2.3.0" def test_opening_an_older_file_completes_the_aircraft_and_the_crate_models(tmp_path): @@ -70,7 +70,7 @@ def test_a_scalar_addition_has_no_container(tmp_path): def test_a_current_file_that_lacks_the_fields_is_left_as_it_is(tmp_path): - body = _open(_stale_yaml(tmp_path, version="2.2.0")) + body = _open(_stale_yaml(tmp_path, version="2.3.0")) assert body["completion"] == [] assert "crateSpawnSector" not in body["values"]["capabilitiesByType"]["Mi-8MT"] diff --git a/tools/ctld-tools/tests/test_oracle.py b/tools/ctld-tools/tests/test_oracle.py index f23e5c79..fdf35a40 100644 --- a/tools/ctld-tools/tests/test_oracle.py +++ b/tools/ctld-tools/tests/test_oracle.py @@ -40,5 +40,5 @@ def test_committed_oracle_is_in_sync_with_the_yaml(tmp_path): def test_oracle_is_valid_json_with_config_version(): data = json.loads(ORACLE.read_text(encoding="utf-8")) - assert data["configVersion"] == "2.2.0" + assert data["configVersion"] == "2.3.0" assert "spawnableCrates" in data diff --git a/tools/ctld-tools/tests/test_web_app.py b/tools/ctld-tools/tests/test_web_app.py index ce9d9dfa..4c622ce0 100644 --- a/tools/ctld-tools/tests/test_web_app.py +++ b/tools/ctld-tools/tests/test_web_app.py @@ -227,7 +227,7 @@ def test_defaults_endpoint_mirrors_the_default_catalogue(): def test_defaults_endpoint_coerces_to_plain_json(): values = client.get("/api/defaults").json()["values"] - assert values["configVersion"] == "2.2.0" + assert values["configVersion"] == "2.3.0" assert isinstance(values["spawnableCrates"], dict) assert isinstance(values["numberOfTroops"], int) @@ -255,7 +255,7 @@ def test_load_text_then_get_catalog(): def test_load_default(): snap = client.post("/api/catalog/load-default").json() - assert snap["values"]["configVersion"] == "2.2.0" + assert snap["values"]["configVersion"] == "2.3.0" assert "spawnableCrates" in snap["values"] @@ -500,10 +500,10 @@ def test_inject_blocked_by_validation_errors(): def test_version_gap_against_default(): - _load() # SAMPLE is configVersion 1.0.0; default is 2.2.0 + _load() # SAMPLE is configVersion 1.0.0; default is 2.3.0 gap = client.get("/api/version-gap").json() assert gap["fromVersion"] == "1.0.0" - assert gap["toVersion"] == "2.2.0" + assert gap["toVersion"] == "2.3.0" assert gap["isEmpty"] is False