From 3b644b74e7d3663cb8849715efb6e9e0dc7bbc38 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 09:41:08 +0100 Subject: [PATCH 01/36] test(fat-cow): in-game spike for a runtime FARP, its finite stock and its vehicles Opens the FEAT-FAT-COW lot. Ticket 01 measures in DCS, before any Fat Cow code, whether a FARP spawned mid-mission serves a player already in game, whether a finite warehouse stock is enforced and seen alike by server and client, and whether an Invisible FARP needs trucks in range. Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 57 ++++ .../tickets/01-spike-runtime-farp.md | 72 +++++ .backlog/README.md | 1 + missions/Test_FatCow_Spike.miz | Bin 0 -> 6514 bytes .../dcs/pilotActive/scenario_fatcow_spike.lua | 275 ++++++++++++++++++ 5 files changed, 405 insertions(+) create mode 100644 .backlog/FEAT-FAT-COW/PRD.md create mode 100644 .backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md create mode 100644 missions/Test_FatCow_Spike.miz create mode 100644 tests/dcs/pilotActive/scenario_fatcow_spike.lua diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md new file mode 100644 index 00000000..eeb9bbf0 --- /dev/null +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -0,0 +1,57 @@ +# FEAT-FAT-COW — a CH-47 that lands in the field and becomes a forward refuel/rearm point + +**Status:** 🧑 waiting-human — ticket 01 (in-game spike) prepared, waiting for Zip's run in DCS; tickets 02+ are written after it, since their shape depends on its answers. + +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 (draft, fixed when the AI ticket is written) + +VMCT will add a CLI + MCP tool that places an AI cow escorting a named flight in a mission. It depends on how this lot declares a cow, so the contract is part of the lot's deliverable and lands in `docs/mission-maker/` (EN + FR): + +- **Config keys** (draft): `fatCow.enabled`, `fatCow.aiTemplate` (the CH-47 group CTLD clones for a requested cow), `fatCow.deployDelay` (60 s), `fatCow.aiStock` (`fuelKg` 7 200, `items` with 6 Hellfires), `fatCow.crate` (fuel per crate 2 400 kg), `fatCow.ammoCrates`. +- **A mission-placed AI cow** is recognised by an explicit config entry (`fatCow.aiCows = { { group = "", escort = "" } }`), not by parsing its name: the engine does not parse names for config-heavy features (see `CONTEXT.md`, "Config-referenced zone"). A tool-only naming convention for VMCT to pre-fill the entry stays possible. + +## Tickets + +| # | Ticket | Status | +|---|---|---| +| 01 | [In-game spike: a runtime FARP, its finite stock, its vehicles](tickets/01-spike-runtime-farp.md) | 🧑 waiting-human (Zip runs it in DCS) | +| 02+ | Core stock + deploy/pack scene, piloted cow crates, AI cow escort, config + VMCT contract doc, release notes | written after 01 | + +## 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..c9503d35 --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -0,0 +1,72 @@ +# 01 — In-game spike: a runtime FARP, its finite stock, its vehicles + +**Status:** 🧑 waiting-human (Zip runs it in DCS) · **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 Single pad FARP ahead (variant) · 6 Remove everything. 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`), air start at 150 m and 80 kt, heading north, 2 km south of the field, 30 % fuel (342 kg), empty pylons. 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, `add_startup_script_trigger` (`file_static`). To rebuild after a script change, redo these three 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. A message `[FCSPIKE] loaded` shows at start. Fly straight ahead and land on open ground, away from trees. Engines stay running. +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. +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. Land if needed (a new air start), then `\` → **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 + +Pending. + +## Acceptance criteria + +- [ ] (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. +- [ ] If an answer kills the design, reported to Zip before ticket 02 is written. diff --git a/.backlog/README.md b/.backlog/README.md index 9df1252b..6451351a 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) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) prepared, Zip runs it in DCS; 02+ written after | 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/missions/Test_FatCow_Spike.miz b/missions/Test_FatCow_Spike.miz new file mode 100644 index 0000000000000000000000000000000000000000..5aaba0a395c00a4e00aab8df465ad3432b9962fc GIT binary patch literal 6514 zcmZ{pWl$X5wuT3n;BLX4!ASxH8{FO9$v|+I3@|_l9vlJ$3GVK0!5xAG3GVKC$+@TM zyN9}Gs#bMX*M8pKtNX|5enwdi4jvBx03ZVp6m$(#!t)325dnZ>CIA5G`K&G2*%@qa zcc5!)zwj38amk44S`C)gpSCTB=(0m5g?0NB+P$Y)9UvkXH!MM|5h}Y%aC7v2H@;f@ zXVF6OHg!gXtGf{SQKUPj%uiYclpiyrGhw6bz(FfU>MY7W_rY%x#HB{Suq^G?OWKAy zEgUNyyc~~$0i7Aa=7T2o*9*jCv)*q!QP!im$@8azcLPSbn6FhQ&z5fFyz78CfR05q z#(0f3BPB29PyRHkRpQ32S)G-esTML}^KVkAeVL`)E_H{j?`5F(2uto>w%Gm?q)bjD z-Ow@5;K0#U-XGmkIZp_q4tmVm6MOKB<4lpU$S8atzqH@k_J3WABF=4f$$RfIMLG3I z1@bGOQneoB}OY!IppJh?kN#9iBKSkc3aHBZ+uAsBFt?gV*23Cw66A zF4 z5~NeFzwma1p563Erb;JP=GCFI?2}xtZ~DYxyP${Xhp3+;#=j{dFr>O7oPH~-gl&1t zoQfr{8$@J!eB*i7@6uZmFbk4JSj9q)VfYk$8-9z{4hW;>37e^IkhDu%&a5Eu9&S{m zX|vkBJ=Hn5Pirv}(&$xyEK;BwdP=pmoG2=xSMH#PWcfHs!jr~6jH@qmjDLdPju_fO z%-MN^juHv`lc#?m5z$Hme{ATm%!Jgs8x{`PzY!o~KATkeK+24Gdl&0PT8M(UITZLWyNv|va5mUx$BeR|{M1?gh^x_&lxW%i^q^xabwvQ_^0ON<>ZM`6dxJR?Kn(u!2 z!2$ZPxyVyj$taUam_)DJP`BeEjf3piw|~-Icv3=!rPjd9B(aMY!15Y z4r1kjM9YfaH^Fqb*7|-^CiSyc*qsjvu_-iw@hPROgt-m%@Vu4 zwD)I+MC=Nm>&TQSTh#am2Yt%RdGnafL*{<_6X>-{sWvA)2<-LKBjXK%<`)fIscEm1 z`w;}STJoG_rZmY+g5lgnK$oelM=?u8t%hRbuZ~*lHvbX@jBeSE(@avc$GpIg51C%C z)H#;=ANR0gd&v{cf82+0Lu)RgxAZEqkE$!BB-qSo@V0 zuLW^j><>CXa~ft#)tVe;BTcg(Z|)b|%^>OP{Si4_rvTw|H+SD%Fz$p^S=1n>InwaL z`9NZ&`nt}I4vqd`1EDa0dFPs7FNyHd=WIp<9V0*6WqD9bqRx&MrKJA4W!nFI6U0r0 zH9Hqyn%5}4vjvR+XN(+Vh7RcWB&QQ;5P(>_xNFLHm8)M5mD1o5EAWN*&oZ{ zPwdOfKU&p2T9R*n$wqgx*U|NQgK5s%QFEGw2ovgfL&IOkzI-N6XPAMj}hbsZf`J2VUTUYC18hdD?1r3^vohRbj!DTp_B8hBi%)H0q+t zarjxsLj7P@E}%g7sEHCe$-Yhg7D}H(qLR04QscA}n<^Mu8NuLQ6S1&@vY`i>_`4=Y z*OeI4IIatEx$jk`?J_!sh5801=as+2GtI5t)V*M(;nl-|dda0oZZ5hq1g9&adW0~4 z5@w8xx)R)$C08Aa?4M%pb_ZLHHg>y&mH%Tv_G3uzVLlH=^ym6JAnhGop68?UAUk9K z&?t*$xyDLiDFWa>8HhtCCfoB`iRVInE_V|rix2j$&KAxrN;$s zK;}MnW|)&A8&^NrjXv27(Si_!8&3`h>xT*}`O&5aah{i9Js06~edOe@V;7f@1Zl{tvzvp>{8#=hlv0F}_?W0FM<*!r2uS zhvk3+Ls3d{K$#KtD0a5KA;bc;PUg%+wQu>^T^g@m?4En_K5ziz`E~;V0Q#4gbvCnj z-a6Rc$kN2c%--F|*#T^0!S>PBq(yhnX`vVYu`F*Elxs%mPpMiPt6mZ_fy3lj944kZ zju{bLMVqklnX=s&q>@-T8N7{qDdROV*AxGhEKf0MmfnBSe)Iux|M2j!D^cK!Y5V}w zSEt0&G@e&oSGGWWaJe!%zVQZblaSh?J2TCy=mx9DP45cYVyQ^{S2i2a>b`vmkZhU+H8sKS!TDp>pjmUOlmg%5S z$o@yR@w0YuQ!4T@q0raP1XU(tDQkOp`AC0QN@D?9&4g3`xlJK8CMDGbdM;$~RX;bJ z*@v*C+)k;!YXUqZocT1`2~2ou8mYO%2>Gob=oWR{q(^)xt=U1|o76~i4n35PW|VZc zPTpCTFh`{@m4ll3CfcS&^Vc~}Om0>JI-YXNd?Jj!9}lt09E{7juL01_6y$mXS>Jaw zjM!B*$XD%wF%}>q%<1~gsKqTwj!z~>nc;$d^uFNk zK!<@3*YX`pm1$HshoP>F;KJ%>>Cvo+$v2B=IjZh}uqj&ZDD57`l;OC_VCYyLc5Cx;%Gva5QK0l{ba%gv&}!tnFe}SrP`>9--O> zW+WQ@jsS`lr5c;4_%dPqbXmumOU61z z1Q&pGxDTD`kzev(Tsn{ZOmTbEM8QNE5iCV$=4>IbjZrot`H?)hbq4c$W+f4lR|GL{ z^VJ5;1BUb%7AP>~NQCdFF-o@hdq!qlctIfv=gU=BejN7#+YNyYNAaU-{TVyH>NnUs z{2|}27qO$;s$yUJ_K^=;UZc{}&rT{51?W^#>2nmh4dtk8Wf%90X)2p0(`_w=6`e?I zHK(u_@cSSNH41nM9~!uh_8uuY5cAW~ZG|A4aLyq}kv_iKE2b>=V>k`ce3XZBmuj)? zBAJpHalVOqR|rM3^q$$n{Mzd6> zjK{zdk2Pz`gCXzf!;fEt$CWC?`QGL?h3lw_byPNt4b3$wPJ~o4WnlO$A0I{Cg8h=- z;oz$gp&G!Ul`j9@lgqR)eaPhae(nW!&f}{%7$U)TTDy6dpk+k;Kck;+Js}UZL2ZVU z>_(^vVW6A$V(j0I8#VG1eln=iNMlWKWfRA5@urw>w>yQNhiW9I$PWBoSPD!&hYgDr zBk0FslQ$7u2aAI{lnz1>UGn;E2WOCUkI*C+%~-0Y=J=`8K@W3s_#g7dt{7v;VmQ6Y zt5$@sk!G6Lo$7yDER$^EjF&L{(2-BPpc6Y8)*{|n=D_j(d?$l}s6|MK<%G$@$23z$ z8T(;CZ9$~bVK05isT%Irb-15wTwV#Bk)%5Pbk~G?Mll(6aX2%-wmn0~_N$xLUNhIn zM(DY9Y+-a@WicG@4ZOXFGD>q31FFp{Dgx6BBIVh^FR&@b9)|wah}kOun7@U@*vvhl3nJhQ#cYwJxKHWtx!UzEy zgMG`}Kpp9*#6y$2El8K}Jr@Cwo14agPI$qNt7*oNH#3OjEq8Rn$h>1znx8z68`n>% z5vEcQzK5P{jOSYhtT-vJsaxN9BNt!t7_yD;&(Eec2ZhJwPgPhGkg{jUr~CP7ZPU3) z(c_ki|3PN6$)+=;WP}7fzwLeOm@Vs5awZsaHR9thwTpE*s8mqINvD+mP8$82gy|PL zUR<4D6P-TuA1lXKBo6uRaNcX{Ql#a>d70KcDLJH9I%PdT13(~IXmF)fr((46F$<|k zw0ElQsaxed;u+$fXaRJ0z~(v&k1IVzTu?LgQDdh$h_IsdYtpp-B?*7AkFN=BU4!V` z{3!05J>A(hUY4Kg@zJ-4dIx zIu&uFJ4|Q{Yg#7UE|W7d)|-;E4pm!rtiI8dl!OUZE0kZiqU4@D1RWuF1HoHx7+&?_ zIuIuy?ZeKmK6}MQZtpWlWi{z&VB2m}ia5AGoYSnsfq7csbM84LXAiY`dc5wGW+ykt zf=D#rY87Nb1!X9sfec!F(FoTV7o9GfjVRz`9^W$)Ll@73$vJMD)Q2$vX3S@NZ_k`d zO>nyAyf0q22gWWV>ENUXIi}9et&;zSb8EGHo8?h|p334na->Ydiga!7X+QX3sz@u9 zqWMdrK4ZXPyXXQFo*s7@4=mmx_Q}+*NiEEHQK%@*)A+!gp<%rxGbyK<1|?$^sQlQjnqZw?xmV_u}Nx9He2HA1^Zxdvx)QsiCJ; zS8s#6sExggjq~N54K5XHyXd%5tSy@j-y{lW1@cX}wx=t1D&LI5u`>-=Ay-=(tFgs` z@H48m_MBo5KfGr8c8;QfVF2=;j(v{40UIaZ%BuuOjw!`=$ z&~@Y_C9C3+MS~)gj@mDQrU9v7Wm#R%t#djRGY^(TtSq12XLlHda5lfwFM5J+P5P>f zU?#-@dq^7|OYvl)0C|J?Gu9W@TTSuO4y~U*^p3fjBGw_NsY838qAq_42TN?tWi#Ss z1qrA&Q#+-i9ofAKKR&FyyBfZtsJ!bf-n{~q%%M{J{I-D1{T(Grndj%44mHFe?1Pt* zP4VTU`;wlG4L*ND42UOz@$7Qu!D7EmOp#447OmMxUGii-vH3WuS0LMRVy#s;duMaA5Nie_^5NRIhR3!Qk1G0>2lcg!hLVfGjS0Ms2E|d^fs<6- zlS~n%=hA9d6HS-4@qUGpAfxZ!R5JF_FYD}9O9mAExatZu_4$5!ZvqQ(c@&&#TD*4} zn1*uAghYNI

zR3dNWHQYi9gD>@lU*=KWB;c5C6ALm^8q}p4EqyjsNfZE?%qUNG8 zharT8%zc7kux|>t2tg7+$uDU z<1&`|9UQorlz8EA*B7U})W34NyE;p^AsAa85F=qzBODtKXEm5n70jCSdH4r@Y>>=n z2}LSgd9d(UHa!mVuH4@hBy> zhkj+h}76 zU!S(Hyj$rx%~7H_enr*Hhfe2gYkFi_Jbj(xoM4#;Gnds2BEvE3vqQz1Meq6jiu&!I z5TV!%a=ECc^Z_1v$8eO=$bg_nFQ~qTqf^}Gnn}uy;i9GB(>z+Ye0B%xCgSIlQGq`> ziB|G3Yj{=~p_8KmJ3)!yQ#kpwvpa3i%lL0(LS?#;WtenG1^xSiq-)DziS;;of#{TX zW07_^5KKOWveh_RFl<8zi6g8-S^Niy!YrfU>uxqVoaqSv|a$2TqWM>K5q&=z1KT8xH*OWJir{PX1R{6oYNo} zWY=DJ-XkH-1cOhNE?Pn5oy&bPAg@gIV|yfDR>X7u#&ehnA|DWHFBV-;jFTw>y`aue zkJ6k_V=7q>y3s6ICgxo2E8Jwh{}GSaE2b+rEnDmC`u_cU`b_4XFjd7*<`O1>bPYBhs tEBTL*|0673ro1!`|D+_nO!=?=L0Jyr`M;FEdIkwl`}|~b|B4sD{{VWJzY_oe literal 0 HcmV?d00001 diff --git a/tests/dcs/pilotActive/scenario_fatcow_spike.lua b/tests/dcs/pilotActive/scenario_fatcow_spike.lua new file mode 100644 index 00000000..db8d35c7 --- /dev/null +++ b/tests/dcs/pilotActive/scenario_fatcow_spike.lua @@ -0,0 +1,275 @@ +---@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. Single pad FARP ahead (variant) — SINGLE_HELIPAD 40 m ahead, if 1 has no warehouse +-- 6. Remove everything — destroys what the spike spawned +-- +-- 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 addTrucks(groupName) + local unit = callerUnit(groupName) + if not unit then say("no unit for group " .. groupName) return end + local farp = S.farpName and StaticObject.getByName(S.farpName) + local p = (farp and farp:isExist()) and farp:getPoint() or unit:getPoint() + local gName = nextName("TRUCKS") + local data = { + name = gName, + task = "Ground Nothing", + units = { + { name = gName .. "_fuel", type = "M978 HEMTT Tanker", x = p.x + 30, y = p.z, heading = 0, skill = "Average" }, + { name = gName .. "_ammo", type = "M 818", x = p.x + 30, y = p.z + 10, heading = 0, skill = "Average" }, + }, + } + 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 + M 818") +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. Single pad FARP ahead (variant)", root, + function(n) spawnFarp(n, "single") end, gName) + missionCommands.addCommandForGroup(gid, "6. Remove everything", root, removeAll) +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 From 2324ed2c0efca2fcb187796747c8d574614e21bf Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 09:44:24 +0100 Subject: [PATCH 02/36] test(fat-cow): start the spike Apaches on the ground, engines running The spike needs no flying: both slots now start hot on the field. Co-Authored-By: Claude Opus 5.5 --- .../tickets/01-spike-runtime-farp.md | 10 +++++----- missions/Test_FatCow_Spike.miz | Bin 6514 -> 6543 bytes 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md index c9503d35..65c0e1b5 100644 --- a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -15,8 +15,8 @@ ## 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 Single pad FARP ahead (variant) · 6 Remove everything. 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`), air start at 150 m and 80 kt, heading north, 2 km south of the field, 30 % fuel (342 kg), empty pylons. 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, `add_startup_script_trigger` (`file_static`). To rebuild after a script change, redo these three actions on a fresh blank mission. +- `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. @@ -25,7 +25,7 @@ The 300 kg of fuel is below the ~800 kg a 30 % Apache takes to fill up, and the 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. A message `[FCSPIKE] loaded` shows at start. Fly straight ahead and land on open ground, away from trees. Engines stay running. +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`. 📸 @@ -33,7 +33,7 @@ Press **Print Screen** (DCS screenshot, saved in `Saved Games\DCS\Screenshots`) 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. +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) @@ -43,7 +43,7 @@ Needs the mission on a dedicated server (the VEAF test server) and Zip joining i 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. Land if needed (a new air start), then `\` → **F8 Ground Crew** → **F1 Rearm & Refuel**. 📸 +4. `\` → **F8 Ground Crew** → **F1 Rearm & Refuel**. 📸 5. Quit. The server's `dcs.log` holds the `[FCSPIKE]` lines. ## Reading the results diff --git a/missions/Test_FatCow_Spike.miz b/missions/Test_FatCow_Spike.miz index 5aaba0a395c00a4e00aab8df465ad3432b9962fc..95cded6bff68bbf51caa2d3783342fde5fbcffef 100644 GIT binary patch delta 2077 zcmY+Fdpr~T9>=#8HuuZe6gHR8j8%-yC>&%%ZMo|)w@2>gE|if_k~`&6>bR9r$)&`D zkweK4M>0e1x5z!Ybk=ju`J>+-pYQke`@Mee?_b~7d;LtB=2^1&k)u!m000IgSUNfl zm8Bj?^8oOorua5pSwzlYDv$OnnD^N+CQcEd(n@KFdW}KCw54dzG zAgd$wWYZ!#8%GaE?4pJs3 zsTJ39!}zM~R(w0ddH1%dmQ+yWbP6A8tNF)dO$vYW;PkcN!;d4mCLrYNX#UcVi#rVB z#ZpY5@wHs=2Rmc?p~4!pH>HuDX!ZT#bQg=ts5wT@MAGA&@qcOGF<7Yk6YuV3INucC z?io}_nJoTzc*FB`k0g4*=0?B*w3B%Ari`|+%Yn9M*V;WnE9d+HS=|Z=pS^Ts>mXX; zilLOvkI<%Giovj?l&L?{3luRj@KW6LiJ8!YE9apXJ=TN?QfcxtzNFS; zCPDcF1}c0;$Uf0&^;UZ$z2v4f!kvR;zAMXeIhYtk0L|~W={=dWDM6It%?>= zihw8=%FQeN`B9&~Rn-uz?cMtw?1HDG%hoYPHsC%8XxhqzK03`|^iSN4_|A@AoqpR| zK+RHp5A!sBae5dbxFvLia~?3&RTz+-KlU+wLN(ALtJ2T=lx+^MhjW1CB+68`#+o8A zl_!BIEcj&H`5JpxTex5RA;i&CkjEGO1w)1#(Z^qVwSj}YD>L#G+dnoQ#0AOsYAy(M zYA2f+Tzo^II>}{5fv&N6Nfn-kOa88|pPHI(Tqfcw$LK(xZvm(UvXSA1qRj#lbz?sW8hm z`U9_d8IIAcPo?|7mJ zYaCZ^)EXU{XGkLz{Bs%Y_q?B394+F#UR2#B9v zn@VaRA%*c#psBh`(YMU9gGSMyVa|6Pjt0^zXXWL0&Fw-uC~>2{ImG+|h7!G1VDfx} zi%H(m(gpRYI9sSa)7DbPW~pO?0JCt2%8n|xK9=AJEeHn+-l_Xi{p{3;EpCBM>4{j{=UYlb%M*C|50i zTp+g&nw12<6JXjALeOWCFAh#zaNu2-4uk+`b0Mpn^KJ2qU?G=IXi!^|D5QuJyk3Yva_ys1&svg#of=YSLh`LcoehHv4R z$zc^BdHiT>r1VI7+ zQ*o9oToxFdMSx3d|N0xRGNZ)*x1|58Wp4ZTi3N6X0ij+VuA#vmzXOuFfSs%-aAoeK rUT%Lj>nmIZ@e8Iouz&NtFn2B*08suviCbfVby=Fyc;0r{ugm`cDiNmN delta 2072 zcmY+Fc{J4f8^>oRV<|J#h?%lvxk4k#5^g2i2$3lhBZV$8WZ%c~HAB}<8Hu6{2GLlO zXo?{_gUA*!N_N8~Bw6ls&hMV{>vPWY{yd-OIp_U(|MyB!TRnZ=(rh1(2nYn?2SLqk zZ(tLPI<|R1pnnuWAU^KYgW%^!@bcu?dU%a$3h$2FOK)C<$dTljG~`Bu$z$dF=f#21 z=d&_7NdZCUC6|+fgiKoH%mwlWdj=DFRM4F+3NpFJ+JZVC8KFw;(FwUWbsKW8tE~^Y z;6-S`&pPmf=iYMOs7Q>W#D_yQLIl3vSCcI2kC~6^IjyvL<-@n}A7-J|=tH1}v8xIX za7_ElM5S<&?3YSIhq~N%FQ&7cO%g@`jVu{J$vAMsmgTl>Lf(ds2N69ElKPG+`gS*w z--pCR_k7XHYci(oL3_NfE7|ld@Qn2;CR6wYwC}{$|MY0TH<^MesPixU)4%^%|E{!N z~5+zMvkHpJNYRI^ZMnxNsoxVdvS5!zet4ceT^fL(5&Hy-nNTyF)pq zsl+Zs{WU{%o_H?Xf97J>hRig3qCVq9Gvc)53~DA$zOl>E0+CfBuEp2wt>r(|;c;6J zUt>3_*)6YfE02nOCrtgmQt#l-^dJ=m|L3wSBvUX*_i%hi5T=p_op}$~Qb2mRG1*qMj1i zw!Y(2xm6x0{0*dR4iprOg0_sQ1>8p2%^n@s!wzUHizoJ;B-am58Ra2;Q|F zmkaEV>r<5@KY%+IfNXCY@?#S5uk?uHIpa=yYDIg+IpgnTnUY1zNCzXzM)BKzsRbdC zF(M4D+&(20uhu}VQHaATC&aIB!eVNP)sAJ%!Qr;k$x(11FLJBq9NaMYt=m;#F_FW8 zb?%jT#U5eO?G6$KavO)X- zqFVsiBHZE-Vg4XeOS#&;_V9QJAu|rRyARG`PAj>5rlWR=5^D! z6KPc+*~5TX32q`iKi~C{5^oXonvW}O>tZeK=asP?&oSEMt82Qm*Mw9xZ~MD*8a+9b zOK2q9BJ@YhA6srknxDninkstkMYL*thTH>9n`xgjj2dkIimgbu%_&?96k8}hAg@T# za@>X=nQBHiZ*M5_o~0?)bc!tsv=ABi8>z8zUb+A)020#Hi_j{=jTji_CQjZySYO)G z+F(SP&<^PC*FTxk>*{THY-7YJb$Usu-D`!si7mA`C9g*ywg%^DK(EE(5XJ+r25P+J5&Zl_(kq+=W2oVk8T@J^{PJi8kC6H(kYMLrx_6AtsA&w;5m|A9N_j zk4;zgWn8e84%Qb;eFUjPp~Hc^`Q~s(gOL_jw4kg$v)EK(!-H<2djH{Fc|pafss5}> zu17cY*vj73k+lyejD#?mDSgi)8_1Lpz~jlStiL^pZ3i-}kIAV-+i+MF!3p)>t+kv{ zX_UM_KyR~!YAYwrgc!1KBee=*5z}`sZZ3A4A$+5B*8+opc>Pm{k1B=1^zXb^^yA+QbTe%Co2f>+ zQOJ>yiFGnMD5Z~XhH0DDsXZ39grsYpH13t_o5sSeb7BZBjs^+O?8{g?vJZZ~@d=nn zy_I%)-O%hw<*j-X{LNy!8}zjoLxxl*w?Hc*OBg#L9g@aObF zhmywhKsM%!cZt_t>d*PkI{T^lM&19TBE^JH4Gg7(^WnJqf$t<&A^aGwn(zmLp_TRg zLPGH0(McH-JFfX_)DFM6AjG98j9b5G`rnBJOLL!I-u_&)`dONRAtIpvA(*0rl+ea; zmqCuc&aPeoe$Kz|xu#Lar{wl?o%p%+yJ-pK9#WJWtPm*y9-%ZN<+=SLx0j~uM;({? i6-e#Fc60}v`!yN_lK(Fa_l^W-P%Kf}yai&vhW`TiroJZt From e3c800f5230fe90030c7735eb0462ff483a3df83 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 09:52:58 +0100 Subject: [PATCH 03/36] docs(fat-cow): record the spike's single-player answers A runtime Invisible FARP serves a player already in game, enforces a finite stock set by script, and serves nothing without trucks in range. Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 4 +-- .../tickets/01-spike-runtime-farp.md | 31 ++++++++++++++++--- .backlog/README.md | 2 +- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index eeb9bbf0..3f12a019 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -1,6 +1,6 @@ # FEAT-FAT-COW — a CH-47 that lands in the field and becomes a forward refuel/rearm point -**Status:** 🧑 waiting-human — ticket 01 (in-game spike) prepared, waiting for Zip's run in DCS; tickets 02+ are written after it, since their shape depends on its answers. +**Status:** 🧑 waiting-human — ticket 01 phase 1 done (2026-10-26): a runtime Invisible FARP serves a player already in game, enforces a finite stock, and needs trucks in range. Phase 2 (multiplayer client) still to run; tickets 02+ are written next. 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. @@ -49,7 +49,7 @@ VMCT will add a CLI + MCP tool that places an AI cow escorting a named flight in | # | Ticket | Status | |---|---|---| -| 01 | [In-game spike: a runtime FARP, its finite stock, its vehicles](tickets/01-spike-runtime-farp.md) | 🧑 waiting-human (Zip runs it in DCS) | +| 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 trucks required); phase 2 to run | | 02+ | Core stock + deploy/pack scene, piloted cow crates, AI cow escort, config + VMCT contract doc, release notes | written after 01 | ## Release notes diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md index 65c0e1b5..5050ca0a 100644 --- a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -1,6 +1,6 @@ # 01 — In-game spike: a runtime FARP, its finite stock, its vehicles -**Status:** 🧑 waiting-human (Zip runs it in DCS) · **Type:** HITL (live DCS, Zip) +**Status:** 🧑 waiting-human — phase 1 done (2026-10-26), phase 2 (multiplayer client) not run yet · **Type:** HITL (live DCS, Zip) ## Parent @@ -63,10 +63,33 @@ Needs the mission on a dedicated server (the VEAF test server) and Zip joining i ## Answers -Pending. +### 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". | +| 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) Yes, vehicles are required.** Without trucks 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. Not measured: whether one truck is enough for both fuel and weapons, and the range. The design keeps the fallback: a vehicle hidden under the cow's fuselage. +- 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 -- [ ] (a), (b) on one machine, (c) answered from `dcs.log` and the screenshots, in this ticket and the PRD. +- [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. -- [ ] If an answer kills the design, reported to Zip before ticket 02 is written. +- [x] If an answer kills the design, reported to Zip before ticket 02 is written. diff --git a/.backlog/README.md b/.backlog/README.md index 6451351a..ad07976c 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +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) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) prepared, Zip runs it in DCS; 02+ written after | 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` | +| [`FEAT-FAT-COW`](FEAT-FAT-COW/PRD.md) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) phase 1 done (serves, finite stock enforced, trucks required), phase 2 (MP client) to run; 02+ next | 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. | From 33f73e793318ad87c6033a5fe56cb8a2f585cf2c Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 09:55:06 +0100 Subject: [PATCH 04/36] test(fat-cow): spike commands for FARP props alone and a fuel truck alone Co-Authored-By: Claude Opus 5.5 --- missions/Test_FatCow_Spike.miz | Bin 6543 -> 7058 bytes .../dcs/pilotActive/scenario_fatcow_spike.lua | 60 ++++++++++++++---- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/missions/Test_FatCow_Spike.miz b/missions/Test_FatCow_Spike.miz index 95cded6bff68bbf51caa2d3783342fde5fbcffef..1bd477e12a6a2dcaf6045efce4baafc3eba45c5d 100644 GIT binary patch delta 6438 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 Date: Mon, 26 Oct 2026 09:59:05 +0100 Subject: [PATCH 05/36] docs(fat-cow): FARP statics supply the ground crew, no vehicle needed Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 4 ++-- .backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md | 4 +++- .backlog/README.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index 3f12a019..721d5c52 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -1,6 +1,6 @@ # FEAT-FAT-COW — a CH-47 that lands in the field and becomes a forward refuel/rearm point -**Status:** 🧑 waiting-human — ticket 01 phase 1 done (2026-10-26): a runtime Invisible FARP serves a player already in game, enforces a finite stock, and needs trucks in range. Phase 2 (multiplayer client) still to run; tickets 02+ are written next. +**Status:** 🧑 waiting-human — 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 FARP statics (fuel depot, ammo dump, barrels, ammo crate) satisfy without any vehicle. Phase 2 (multiplayer client) still to run; tickets 02+ are written next. 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. @@ -49,7 +49,7 @@ VMCT will add a CLI + MCP tool that places an AI cow escorting a named flight in | # | 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 trucks required); phase 2 to run | +| 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 statics suffice, no vehicle); phase 2 to run | | 02+ | Core stock + deploy/pack scene, piloted cow crates, AI cow escort, config + VMCT contract doc, release notes | written after 01 | ## Release notes diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md index 5050ca0a..40bec6e2 100644 --- a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -75,11 +75,13 @@ From `dcs.log` and Zip's screenshots. | 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) Yes, vehicles are required.** Without trucks 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. Not measured: whether one truck is enough for both fuel and weapons, and the range. The design keeps the fallback: a vehicle hidden under the cow's fuselage. +- **(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. Not separated: which of the four statics does it (fuel depot or barrels, ammo dump or ammo crate), and the range. The fuel-truck-only test (command 8) is moot. +- 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. diff --git a/.backlog/README.md b/.backlog/README.md index ad07976c..7365dfb4 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +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) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) phase 1 done (serves, finite stock enforced, trucks required), phase 2 (MP client) to run; 02+ next | 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` | +| [`FEAT-FAT-COW`](FEAT-FAT-COW/PRD.md) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) phase 1 done (serves, finite stock enforced, statics suffice, no vehicle), phase 2 (MP client) to run; 02+ next | 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. | From 957a245dd4fd00b61ac72f48f95f1992287ddf38 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:01:15 +0100 Subject: [PATCH 06/36] test(fat-cow): spike commands to find which FARP static supplies Co-Authored-By: Claude Opus 5.5 --- .../tickets/01-spike-runtime-farp.md | 2 +- missions/Test_FatCow_Spike.miz | Bin 7058 -> 7134 bytes .../dcs/pilotActive/scenario_fatcow_spike.lua | 36 +++++++++++------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md index 40bec6e2..5bf87ef4 100644 --- a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -14,7 +14,7 @@ ## 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 Single pad FARP ahead (variant) · 6 Remove everything. Every measurement goes to the screen and to `dcs.log`, prefixed `[FCSPIKE]`. +- `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 Barrels only · 9 Ammo crate 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. diff --git a/missions/Test_FatCow_Spike.miz b/missions/Test_FatCow_Spike.miz index 1bd477e12a6a2dcaf6045efce4baafc3eba45c5d..2b8aa50ba735062f4266a8f25e516382ff4f2d7a 100644 GIT binary patch delta 4559 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 delta 4496 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 diff --git a/tests/dcs/pilotActive/scenario_fatcow_spike.lua b/tests/dcs/pilotActive/scenario_fatcow_spike.lua index 60cd0565..1e630e9f 100644 --- a/tests/dcs/pilotActive/scenario_fatcow_spike.lua +++ b/tests/dcs/pilotActive/scenario_fatcow_spike.lua @@ -19,10 +19,11 @@ -- 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. Single pad FARP ahead (variant) — SINGLE_HELIPAD 40 m ahead, if 1 has no warehouse +-- 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. Add fuel truck only — the M978 alone: does one vehicle serve weapons too? +-- 8. Barrels only — barrels_cargo alone +-- 9. Ammo crate only — ammo_cargo 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. @@ -227,20 +228,27 @@ local function addTrucks(groupName, fuelOnly) say("TRUCKS spawned 30 m north of the FARP: M978 HEMTT Tanker" .. (fuelOnly and " only" or " + M 818")) end --- Statics only, no vehicle: do FARP props count as suppliers? Shapes from DCS's db.Units.lua. +-- Statics only, no vehicle: which FARP props count as suppliers? Shapes from DCS's db.Units.lua. local PROPS = { - { type = "FARP Fuel Depot", shape_name = "GSM Rus", category = "Fortifications", dx = 30, dz = 0 }, - { type = "FARP Ammo Dump Coating", shape_name = "SetkaKP", category = "Fortifications", dx = 30, dz = 15 }, - { type = "barrels_cargo", shape_name = "barrels_cargo", category = "Cargos", dx = 25, dz = -10 }, - { type = "ammo_cargo", shape_name = "ammo_box_cargo", category = "Cargos", dx = 25, dz = 25 }, + 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" }, + barrels = { "barrels" }, + crate = { "crate" }, } -local function addProps(groupName) +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 _, d in ipairs(PROPS) do + 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, @@ -283,12 +291,14 @@ local function buildMenu(unit) 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. Single pad FARP ahead (variant)", root, - function(n) spawnFarp(n, "single") end, 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. Add fuel truck only", root, - function(n) addTrucks(n, true) end, gName) + missionCommands.addCommandForGroup(gid, "8. Barrels only", root, + function(n) addProps(n, "barrels") end, gName) + missionCommands.addCommandForGroup(gid, "9. Ammo crate only", root, + function(n) addProps(n, "crate") end, gName) end S.handler = {} From 65240a5a90c96eea36b6ba88c52dc8b3aa4dab5f Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:03:03 +0100 Subject: [PATCH 07/36] test(fat-cow): spike commands for the fuel depot and the ammo dump alone Co-Authored-By: Claude Opus 5.5 --- .../tickets/01-spike-runtime-farp.md | 2 +- missions/Test_FatCow_Spike.miz | Bin 7134 -> 7135 bytes .../dcs/pilotActive/scenario_fatcow_spike.lua | 16 ++++++++-------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md index 5bf87ef4..f814dc92 100644 --- a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -14,7 +14,7 @@ ## 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 Barrels only · 9 Ammo crate 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]`. +- `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. diff --git a/missions/Test_FatCow_Spike.miz b/missions/Test_FatCow_Spike.miz index 2b8aa50ba735062f4266a8f25e516382ff4f2d7a..d252b62e37de07abb988d65b3063a150d195d5b9 100644 GIT binary patch delta 4326 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 U%@q*<0P~ZL8%qWg8UO$Q06NS*K>z>% delta 4321 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 diff --git a/tests/dcs/pilotActive/scenario_fatcow_spike.lua b/tests/dcs/pilotActive/scenario_fatcow_spike.lua index 1e630e9f..dc7b4484 100644 --- a/tests/dcs/pilotActive/scenario_fatcow_spike.lua +++ b/tests/dcs/pilotActive/scenario_fatcow_spike.lua @@ -22,8 +22,8 @@ -- 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. Barrels only — barrels_cargo alone --- 9. Ammo crate only — ammo_cargo alone +-- 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. @@ -238,8 +238,8 @@ local PROPS = { local PROP_SETS = { all = { "depot", "dump", "barrels", "crate" }, crates = { "barrels", "crate" }, - barrels = { "barrels" }, - crate = { "crate" }, + depot = { "depot" }, + dump = { "dump" }, } local function addProps(groupName, set) @@ -295,10 +295,10 @@ local function buildMenu(unit) 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. Barrels only", root, - function(n) addProps(n, "barrels") end, gName) - missionCommands.addCommandForGroup(gid, "9. Ammo crate only", root, - function(n) addProps(n, "crate") end, 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 = {} From 1548066c86307c9e3f42905b4378f5cd7740bae1 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:09:24 +0100 Subject: [PATCH 08/36] docs(fat-cow): the FARP Fuel Depot alone supplies fuel and weapons Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 4 ++-- .../FEAT-FAT-COW/tickets/01-spike-runtime-farp.md | 11 ++++++++++- .backlog/README.md | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index 721d5c52..4b32ce2c 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -1,6 +1,6 @@ # FEAT-FAT-COW — a CH-47 that lands in the field and becomes a forward refuel/rearm point -**Status:** 🧑 waiting-human — 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 FARP statics (fuel depot, ammo dump, barrels, ammo crate) satisfy without any vehicle. Phase 2 (multiplayer client) still to run; tickets 02+ are written next. +**Status:** 🧑 waiting-human — 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 (multiplayer client) still to run; tickets 02+ are written next. 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. @@ -49,7 +49,7 @@ VMCT will add a CLI + MCP tool that places an AI cow escorting a named flight in | # | 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 statics suffice, no vehicle); phase 2 to run | +| 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 stock + deploy/pack scene, piloted cow crates, AI cow escort, config + VMCT contract doc, release notes | written after 01 | ## Release notes diff --git a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md index f814dc92..f45857fa 100644 --- a/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md +++ b/.backlog/FEAT-FAT-COW/tickets/01-spike-runtime-farp.md @@ -80,7 +80,16 @@ From `dcs.log` and Zip's screenshots. - **(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. Not separated: which of the four statics does it (fuel depot or barrels, ammo dump or ammo crate), and the range. The fuel-truck-only test (command 8) is moot. +- **(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. diff --git a/.backlog/README.md b/.backlog/README.md index 7365dfb4..c77b9636 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +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) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) phase 1 done (serves, finite stock enforced, statics suffice, no vehicle), phase 2 (MP client) to run; 02+ next | 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` | +| [`FEAT-FAT-COW`](FEAT-FAT-COW/PRD.md) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) phase 1 done (serves, finite stock enforced, the FARP Fuel Depot static alone supplies, no vehicle), phase 2 (MP client) to run; 02+ next | 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. | From bb524cdf1af24dff13950daa73c690e2fa9fa898 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:12:23 +0100 Subject: [PATCH 09/36] docs(fat-cow): tickets 02-06 of the lot Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 8 +++++-- .../tickets/02-core-stock-and-scene.md | 22 +++++++++++++++++++ .../FEAT-FAT-COW/tickets/03-piloted-cow.md | 21 ++++++++++++++++++ .backlog/FEAT-FAT-COW/tickets/04-ai-cow.md | 20 +++++++++++++++++ .../tickets/05-config-docs-release.md | 17 ++++++++++++++ .../FEAT-FAT-COW/tickets/06-live-check.md | 14 ++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 .backlog/FEAT-FAT-COW/tickets/02-core-stock-and-scene.md create mode 100644 .backlog/FEAT-FAT-COW/tickets/03-piloted-cow.md create mode 100644 .backlog/FEAT-FAT-COW/tickets/04-ai-cow.md create mode 100644 .backlog/FEAT-FAT-COW/tickets/05-config-docs-release.md create mode 100644 .backlog/FEAT-FAT-COW/tickets/06-live-check.md diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index 4b32ce2c..c81c6f33 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -1,6 +1,6 @@ # FEAT-FAT-COW — a CH-47 that lands in the field and becomes a forward refuel/rearm point -**Status:** 🧑 waiting-human — 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 (multiplayer client) still to run; tickets 02+ are written next. +**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. Implementation in progress (Zip, 2026-10-26: "implémente"). 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. @@ -50,7 +50,11 @@ VMCT will add a CLI + MCP tool that places an AI cow escorting a named flight in | # | 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 stock + deploy/pack scene, piloted cow crates, AI cow escort, config + VMCT contract doc, release notes | written after 01 | +| 02 | [Core: a cow's stock, its scene, deploy and pack](tickets/02-core-stock-and-scene.md) | 🔄 in-progress | +| 03 | [Piloted cow: cow crates in a CH-47F and the F10 commands](tickets/03-piloted-cow.md) | ⬜ ready | +| 04 | [AI cow: requested by a flight, escorts it, serves it, goes home](tickets/04-ai-cow.md) | ⬜ ready | +| 05 | [Config, VMCT contract doc, CHANGELOG and release notes](tickets/05-config-docs-release.md) | ⬜ ready | +| 06 | [Live check in DCS](tickets/06-live-check.md) | ⬜ ready (HITL) | ## Release notes 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..4630d96f --- /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:** 🔄 in-progress · **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 + +- [ ] 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`. +- [ ] 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..3986d75d --- /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:** ⬜ ready · **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 + +- [ ] 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..8f4aaf48 --- /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:** ⬜ ready · **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 + +- [ ] 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..e6335948 --- /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:** ⬜ ready · **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 + +- [ ] `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..af3ab838 --- /dev/null +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -0,0 +1,14 @@ +# 06 — Live check in DCS + +**Status:** ⬜ ready · **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). +- If a second player is available: the client side of ticket 01 (b). From a69637ab659afa58e86a53b20587d1b0fc4225e9 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:14:11 +0100 Subject: [PATCH 10/36] feat(fat-cow): a cow's stock and the FARP scene it lays out Co-Authored-By: Claude Opus 5.5 --- src/CTLD_fatCow.lua | 76 +++++++++++++++ src/scenes/CTLD_fatCowScene.lua | 148 +++++++++++++++++++++++++++++ tests/ci/unit/fatcow_core_spec.lua | 86 +++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 src/CTLD_fatCow.lua create mode 100644 src/scenes/CTLD_fatCowScene.lua create mode 100644 tests/ci/unit/fatcow_core_spec.lua diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua new file mode 100644 index 00000000..1c2f7fe6 --- /dev/null +++ b/src/CTLD_fatCow.lua @@ -0,0 +1,76 @@ +---@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, CTLDUtils +-- ==================================================================================================== + +-- 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 + +--- Writes the stock into a FARP warehouse: jet fuel, every other liquid at 0, each stocked item. +function CTLDFatCow:writeWarehouse(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 + +--- Reads the stock back from the FARP warehouse: what the players took is gone. +function CTLDFatCow:readWarehouse(w) + self.stock.fuelKg = w:getLiquidAmount(JET_FUEL) or 0 + for name in pairs(self.stock.items) do + self.stock.items[name] = 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 diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua new file mode 100644 index 00000000..ddd872c2 --- /dev/null +++ b/src/scenes/CTLD_fatCowScene.lua @@ -0,0 +1,148 @@ +---@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 +-- FARP Fuel Depot — 25 m behind the ramp (180°): the supplier +-- Ammo crates — 15 m / 160° and 15 m / 200°: dressing +-- Barrels — 18 m / 145°: dressing +-- 3 infantry — 20 m / 215°, standing by +-- 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" + +fatCowScene.steps = { + + -- Step 1: the Invisible FARP at the cow. Its name is what the warehouse is looked up by. + { + polar = { distance = 0, angle = 0 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "FatCow_FARP", + critical = true, + func = function(ctx) + ctx.scene._params.farpName = ctx.spawnedObj:getName() + return true + end, + }, + + -- Step 2: the fuel depot behind the ramp — the only supplier the FARP needs. + { + polar = { distance = 25, angle = 180 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "FatCow_Fuel_Depot", + critical = true, + }, + + -- Steps 3-5: dressing. + { + polar = { distance = 15, angle = 160 }, + delayAfterPreviousStep = 2, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "ammo_cargo", + }, + { + polar = { distance = 15, angle = 200 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 30, + relativeAltitudeInMeters = 0, + registryKey = "ammo_cargo", + }, + { + polar = { distance = 18, angle = 145 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "barrels_cargo", + }, + + -- Step 6: the crew standing by. + { + polar = { distance = 20, angle = 215 }, + delayAfterPreviousStep = 0, + relativeHeadingInDegrees = 0, + relativeAltitudeInMeters = 0, + registryKey = "FatCow_Crew", + }, + + -- Step 7: 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). +-- ==================================================================================================== + +CTLDObjectRegistry.registerIfAbsent("FatCow_FARP", { + groupType = "STATIC", + namePrefix = "FatCow_FARP", + type = "Invisible FARP", + shape_name = "invisiblefarp", + category = "Heliports", + heliport_frequency = "127.5", + heliport_callsign_id = 1, + heliport_modulation = 0, + rate = 100, +}) + +-- 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/unit/fatcow_core_spec.lua b/tests/ci/unit/fatcow_core_spec.lua new file mode 100644 index 00000000..91384420 --- /dev/null +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -0,0 +1,86 @@ +---@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. +-- ============================================================ + +local _root = debug.getinfo(1, "S").source:match("^@(.+)tests[\\/]ci[\\/]unit[\\/]fatcow_core_spec%.lua$") or "" +dofile(_root .. "src/CTLD_fatCow.lua") + +-- 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 + 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) +end) From baed90c97092ea56ad46928f006d7979017a22c2 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:16:38 +0100 Subject: [PATCH 11/36] feat(fat-cow): deploy, pack and re-sync a cow's FARP Co-Authored-By: Claude Opus 5.5 --- src/CTLD_fatCow.lua | 114 ++++++++++++++++++++++ src/scenes/CTLD_fatCowScene.lua | 28 +++++- tests/ci/helpers/loader.lua | 1 + tests/ci/unit/fatcow_core_spec.lua | 124 +++++++++++++++++++++++- tests/ci/unit/scene_asset_gate_spec.lua | 2 +- tools/build/listToMerge.txt | 2 + 6 files changed, 263 insertions(+), 8 deletions(-) diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 1c2f7fe6..c9af9e95 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -74,3 +74,117 @@ function CTLDFatCow:resyncWarehouse(w) self:readWarehouse(w) self:writeWarehouse(w) 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 +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" +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 }) + if not scene then + cow.state = CTLDFatCow.STATE_STOWED + return false, "scene" + 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) + 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) +end + +--- Reads what the players left back into the stock, then removes the scene. +-- @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 + local w = warehouseOf(cow.farpName) + if w then cow:readWarehouse(w) end + if cow.scene then + 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 open FARP's warehouse re-written (see resyncWarehouse). +function CTLDFatCowManager:onBirth(event) + 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 + for _, cow in pairs(self._cows) do + if cow.state == CTLDFatCow.STATE_DEPLOYED then + local w = warehouseOf(cow.farpName) + if w then cow:resyncWarehouse(w) end + end + end +end diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua index ddd872c2..e8ff0601 100644 --- a/src/scenes/CTLD_fatCowScene.lua +++ b/src/scenes/CTLD_fatCowScene.lua @@ -9,7 +9,7 @@ -- 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 -- FARP Fuel Depot — 25 m behind the ramp (180°): the supplier --- Ammo crates — 15 m / 160° and 15 m / 200°: dressing +-- Ammo crates — 15 m / 160° and 15 m / 200°: dressing (never loadable: "CTLD_" name prefix) -- Barrels — 18 m / 145°: dressing -- 3 infantry — 20 m / 215°, standing by -- Warehouse — set to the cow's stock (ctx.scene._params.fatCow) once laid out @@ -53,21 +53,21 @@ fatCowScene.steps = { delayAfterPreviousStep = 2, relativeHeadingInDegrees = 0, relativeAltitudeInMeters = 0, - registryKey = "ammo_cargo", + registryKey = "FatCow_Ammo", }, { polar = { distance = 15, angle = 200 }, delayAfterPreviousStep = 0, relativeHeadingInDegrees = 30, relativeAltitudeInMeters = 0, - registryKey = "ammo_cargo", + registryKey = "FatCow_Ammo", }, { polar = { distance = 18, angle = 145 }, delayAfterPreviousStep = 0, relativeHeadingInDegrees = 0, relativeAltitudeInMeters = 0, - registryKey = "barrels_cargo", + registryKey = "FatCow_Barrels", }, -- Step 6: the crew standing by. @@ -105,6 +105,26 @@ CTLDObjectRegistry.registerIfAbsent("FatCow_FARP", { rate = 100, }) +-- Dressing cargo statics. The "CTLD_" prefix keeps CTLDCrateManager:onBirth from registering them as +-- loadable crates (it registers every other cargo static that appears). +CTLDObjectRegistry.registerIfAbsent("FatCow_Ammo", { + groupType = "STATIC", + namePrefix = "CTLD_FatCow_Ammo", + type = "ammo_cargo", + category = "Cargos", + shape_name = "ammo_box_cargo", + rate = 1, +}) + +CTLDObjectRegistry.registerIfAbsent("FatCow_Barrels", { + groupType = "STATIC", + namePrefix = "CTLD_FatCow_Barrels", + type = "barrels_cargo", + category = "Cargos", + shape_name = "barrels_cargo", + rate = 100, +}) + -- Shape from DCS's db.Units.lua ("FARP Fuel Depot", ShapeName "GSM Rus"). CTLDObjectRegistry.registerIfAbsent("FatCow_Fuel_Depot", { groupType = "STATIC", 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_core_spec.lua b/tests/ci/unit/fatcow_core_spec.lua index 91384420..cf461f84 100644 --- a/tests/ci/unit/fatcow_core_spec.lua +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -3,9 +3,6 @@ -- FEAT-FAT-COW ticket 02: a cow's stock, written into its FARP warehouse on deploy, read back on pack. -- ============================================================ -local _root = debug.getinfo(1, "S").source:match("^@(.+)tests[\\/]ci[\\/]unit[\\/]fatcow_core_spec%.lua$") or "" -dofile(_root .. "src/CTLD_fatCow.lua") - -- 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 = {} } @@ -84,3 +81,124 @@ describe("CTLDFatCow stock", function() assert.is_true(writes > 0) 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 } + params.farpName = "FatCow_FARP-1" + mgr:onSceneLaidOut(scene) + return scene + end + sm.packScene = function(_, scene) packed = scene; return {} end + Airbase.getByName = function(name) + if name == "FatCow_FARP-1" 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("FatCow_FARP-1", cow.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) + 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 } + mgr:onBirth({ initiator = player }) + assert.equals(4000, mgr:getCow("Cow-1").stock.fuelKg) + assert.equals(4000, 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/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/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) From 3ae0b9db4c410628158537bc40b82d0953db5f53 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:20:08 +0100 Subject: [PATCH 12/36] feat(fat-cow): a piloted cow's stock is the cow crates in its hold Co-Authored-By: Claude Opus 5.5 --- src/CTLD_config.yaml | 22 ++++++ src/CTLD_crate.lua | 6 +- src/CTLD_fatCow.lua | 61 +++++++++++++++++ tests/ci/unit/fatcow_piloted_spec.lua | 96 +++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 tests/ci/unit/fatcow_piloted_spec.lua diff --git a/src/CTLD_config.yaml b/src/CTLD_config.yaml index e5947482..22af368e 100644 --- a/src/CTLD_config.yaml +++ b/src/CTLD_config.yaml @@ -276,6 +276,28 @@ mm_facing: - extract25 farpTroopPickupRadius: 150 fastRopeMaximumHeight: 18.28 + fatCowAICows: [] + fatCowAIFuelKg: 7200 + fatCowAIItems: + - item: weapons.missiles.AGM_114K + count: 6 + fatCowAITemplate: "" + fatCowCrates: + - desc: Fat Cow fuel tank (800 gal) + weight: 2400 + fuelKg: 2400 + - desc: Fat Cow Hellfires (x4) + weight: 250 + item: weapons.missiles.AGM_114K + count: 4 + - desc: Fat Cow 30 mm (x300) + weight: 150 + item: weapons.shells.M230_HEDP M789 + count: 300 + fatCowDeployDelay: 60 + fatCowEnabled: true + fatCowTypes: + - CH-47Fbl1 fobCrateCollectionRadius: 750 fobDestructionThreshold: 0.5 fobLogisticZoneRadius: 150 diff --git a/src/CTLD_crate.lua b/src/CTLD_crate.lua index 84a589e3..6c36997a 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 @@ -1101,7 +1102,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 diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index c9af9e95..987df235 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -75,6 +75,67 @@ function CTLDFatCow:resyncWarehouse(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 + +--- 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 -- ==================================================================================================== diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua new file mode 100644 index 00000000..ca903abe --- /dev/null +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -0,0 +1,96 @@ +---@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("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("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) From 73b2f8634cecff39814e392a6189f03fd4dbd710 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:21:53 +0100 Subject: [PATCH 13/36] feat(fat-cow): F10 Fat Cow section for a piloted CH-47F Co-Authored-By: Claude Opus 5.5 --- src/CTLD_core.lua | 5 + src/CTLD_fatCow.lua | 259 ++++++++++++++++++++++++++ tests/ci/unit/fatcow_piloted_spec.lua | 123 ++++++++++++ 3 files changed, 387 insertions(+) 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_fatCow.lua b/src/CTLD_fatCow.lua index 987df235..3ec13743 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -88,6 +88,18 @@ local function isCowCrate(crate) return crate and crate.descriptor and crate.descriptor.fatCow ~= nil end +--- "2400 kg of fuel, AGM_114K 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", (name:gsub("^weapons%.[%a_]+%.", "")), 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 = {} } @@ -217,6 +229,14 @@ function CTLDFatCowManager:onSceneLaidOut(scene) 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 + +--- 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, then removes the scene. @@ -249,3 +269,242 @@ function CTLDFatCowManager:onBirth(event) 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") + ctld.utils.log("INFO", "CTLDFatCowManager: started, %d cow crate type(s)", #self._crateDescs) +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 = {} + for _, def in ipairs(ctld.gs("fatCowCrates") or {}) do + local content = (def.fuelKg and { fuelKg = def.fuelKg }) + or (def.item and def.count 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 + local w = def.weight + while cm._weightIndex[w] do + w = math.floor(w * 100 + 1.5) / 100 + end + if w ~= def.weight then + ctld.utils.log("WARN", "CTLDFatCowManager: crate '%s' weight %.2f taken — reassigned to %.2f", + def.desc, def.weight, w) + end + local d = { + weight = w, + desc = ctld.tr(def.desc), + unit = "FatCow:" .. def.desc, + cratesRequired = 1, + showSets = false, + fatCow = content, + } + cm._weightIndex[w] = d + 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 +-- ==================================================================================================== + +local function groupIdOf(unit) + return ctld.utils.getGroupId(unit) +end + +--- 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 = groupIdOf(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 + +--- 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 = groupIdOf(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 + 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 groupIdOf(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 + local cm = CTLDCrateManager.getInstance() + local stillOnboard = {} + for _, crate in ipairs(cow.crates) do + if crate:isLoaded() and cm:isCarriedBy(crate, unitName) then stillOnboard[#stillOnboard + 1] = crate end + end + for _, crate in ipairs(CTLDFatCow.refillCrates(cow.stock, stillOnboard)) 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 + +function CTLDFatCowManager:reportStock(unitName) + 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(groupIdOf(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. +function CTLDFatCowManager:onDead(event) + local name = ctld.utils.safeObjectName(event and event.initiator) + if name and self._cows[name] then + self:removeCow(name) + self:_forgetAICow(name) + end +end + +-- ── Menu section ──────────────────────────────────────────────────────────────────────────────── + +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 + for i, d in ipairs(self._crateDescs or {}) do + menu:addCommand(path, ctld.tr("Request %1", 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("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 }) + 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 hooks — defined with the AI cow (ticket 04). +function CTLDFatCowManager:onLand(_) end +function CTLDFatCowManager:_onAITakeoff(_) end +function CTLDFatCowManager:_forgetAICow(_) end +function CTLDFatCowManager:_buildAIMenu(_, _, _) end diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua index ca903abe..4f3f51f9 100644 --- a/tests/ci/unit/fatcow_piloted_spec.lua +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -61,6 +61,129 @@ describe("Fat Cow crates on board", function() 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 } + params.farpName = "FatCow_FARP-1" + mgr:onSceneLaidOut(scene) + return scene + end + sm.packScene = function() return {} end + Airbase.getByName = function(n) if n == "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("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("CTLDCrateManager:getLoadedCrateWeight with a part-used crate", function() local cm, saved From 0197490b411af3331051ed648f1da6d856ca8060 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:24:52 +0100 Subject: [PATCH 14/36] feat(fat-cow): AI cow that escorts a flight and serves it in the field Co-Authored-By: Claude Opus 5.5 --- src/CTLD_config.yaml | 2 +- src/CTLD_fatCow.lua | 311 ++++++++++++++++++++++++++++++- tests/ci/unit/fatcow_ai_spec.lua | 230 +++++++++++++++++++++++ 3 files changed, 535 insertions(+), 8 deletions(-) create mode 100644 tests/ci/unit/fatcow_ai_spec.lua diff --git a/src/CTLD_config.yaml b/src/CTLD_config.yaml index 22af368e..2ddd919a 100644 --- a/src/CTLD_config.yaml +++ b/src/CTLD_config.yaml @@ -281,7 +281,7 @@ mm_facing: fatCowAIItems: - item: weapons.missiles.AGM_114K count: 6 - fatCowAITemplate: "" + fatCowAIType: CH-47Fbl1 fatCowCrates: - desc: Fat Cow fuel tank (800 gal) weight: 2400 diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 3ec13743..a830ded6 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -258,6 +258,7 @@ end --- S_EVENT_BIRTH: a player arriving gets every open FARP's warehouse re-written (see resyncWarehouse). 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) @@ -284,6 +285,7 @@ function CTLDFatCowManager:start() 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") + self:_activateMissionCows() ctld.utils.log("INFO", "CTLDFatCowManager: started, %d cow crate type(s)", #self._crateDescs) end @@ -432,7 +434,8 @@ function CTLDFatCowManager:packIntoHold(unitName, quiet) end end -function CTLDFatCowManager:reportStock(unitName) +-- @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] @@ -444,7 +447,7 @@ function CTLDFatCowManager:reportStock(unitName) else stock = CTLDFatCow.stockOfCrates(cowCratesOnboard(unitName)) end - trigger.action.outTextForGroup(groupIdOf(t), ctld.tr("Fat Cow stock: %1", CTLDFatCow.describeStock(stock)), 15) + trigger.action.outTextForGroup(toGroupId or groupIdOf(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). @@ -503,8 +506,302 @@ CTLDPlayerManager.deferMenuSection({ order = 50, }) --- AI cow hooks — defined with the AI cow (ticket 04). -function CTLDFatCowManager:onLand(_) end -function CTLDFatCowManager:_onAITakeoff(_) end -function CTLDFatCowManager:_forgetAICow(_) end -function CTLDFatCowManager:_buildAIMenu(_, _, _) end +-- ==================================================================================================== +-- 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 + +-- Internal fuel given to a scripted cow: the CH-47Fbl1's M_fuel_max in DCS's db.Units.lua. +-- A scripted aircraft created without fuel falls out of the sky. +local AI_COW_FUEL_KG = 3054 +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 function aiStockFromConfig() + local stock = { fuelKg = ctld.gs("fatCowAIFuelKg") or 0, items = {} } + for _, it in ipairs(ctld.gs("fatCowAIItems") or {}) do + if it.item and it.count then stock.items[it.item] = 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 = 3600 }, + } } } }, + } } } }, + } +end + +local function setCargoMass(cow) + local kg = cow.stock.fuelKg or 0 + for _, count in pairs(cow.stock.items) do kg = kg + 50 * count end -- ~50 kg a missile + pcall(trigger.action.setUnitInternalCargo, cow.unitName, kg) +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 +function CTLDFatCowManager:addAICow(groupName, flightGroup, homePoint) + local unit = firstUnitOf(groupName) + if not unit then return nil end + local cow = self:addCow(unit, aiStockFromConfig()) + 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 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 escorted flight lands → the cow lands next to it; the cow lands → it deploys +-- after fatCowDeployDelay, or despawns when it was going home. +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 + 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") or 60)) + end + return + end + + local okG, group = pcall(unit.getGroup, unit) + local flight = okG and group and group:getName() + local escort = flight and self:aiCowOfFlight(flight) + if escort and escort.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING then + self:_landNextTo(escort, unit) + end +end + +--- The escorted flight takes off → the cow packs and follows again, or goes home if empty. +function CTLDFatCowManager:_onAITakeoff(name) + local unit = Unit.getByName(name) + if not unit then return end + local okG, group = pcall(unit.getGroup, unit) + local flight = okG and group and group:getName() + local cow = flight and self:aiCowOfFlight(flight) + if not cow or cow.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING 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 + +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 + +--- F10 "Request a Fat Cow": only on the ground inside a friendly logistic zone, one cow per flight. +function CTLDFatCowManager:requestAICow(unitName) + local t = Unit.getByName(unitName) + if not (t and t:isExist()) then return end + local gid = groupIdOf(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 ctld.utils.inAir(t) then + trigger.action.outTextForGroup(gid, ctld.tr("Land in a logistic zone to request a Fat Cow."), 10) + return + end + local zone = CTLDZoneManager.getInstance():getLogisticZoneAtPoint(t:getPoint(), t:getCoalition()) + if not zone then + trigger.action.outTextForGroup(gid, ctld.tr("Land in a logistic zone to request a Fat Cow."), 10) + return + end + self._aiCounter = (self._aiCounter or 0) + 1 + local p = t:getPoint() + local hdg = ctld.utils.getHeadingInRadians("CTLDFatCowManager", t, true) + local sx, sz = p.x - math.cos(hdg) * 80, p.z - math.sin(hdg) * 80 -- 80 m behind the requester + local groupName = string.format("Fat Cow %s #%d", flight, self._aiCounter) + local data = { + name = groupName, + task = "Transport", + route = { points = { { + type = "TakeOffGroundHot", action = "From Ground Area Hot", + x = sx, y = sz, alt = land.getHeight({ x = sx, y = sz }), alt_type = "BARO", speed = 0, + task = { id = "ComboTask", params = { tasks = {} } }, + } } }, + units = { { + name = groupName .. "-1", type = ctld.gs("fatCowAIType"), skill = "High", + x = sx, y = sz, 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 home = { x = sx, y = p.y, z = sz } + local mgr = self + -- The group exists at once, its controller takes a task better on the next frame. + ctld.scheduler.schedule(function() mgr:addAICow(groupName, flight, home) end, nil, timer.getTime() + 1) + trigger.action.outTextForGroup(gid, + ctld.tr("Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later.", + ctld.gs("fatCowDeployDelay") or 60), 15) +end + +--- F10 "Release the Fat Cow": 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(groupIdOf(t), ctld.tr("Your flight has no Fat Cow."), 10) + return + end + self:_returnHome(cow) + trigger.action.outTextForGroup(groupIdOf(t), ctld.tr("Fat Cow released: it goes home."), 10) +end + +function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) + if (ctld.gs("fatCowAIType") or "") == "" then return end + menu:addSubMenu({ path[1] }, path[2], { order = 50 }) + local mgr = CTLDFatCowManager.getInstance + menu:addCommand(path, ctld.tr("Request a Fat Cow"), + function(arg) mgr():requestAICow(arg.unitName) end, { unitName = playerObj.unitName }, { order = 1 }) + menu:addCommand(path, ctld.tr("Release the Fat Cow"), + function(arg) mgr():releaseAICow(arg.unitName) end, { unitName = playerObj.unitName }, { order = 2 }) + menu:addCommand(path, ctld.tr("Fat Cow stock"), + function(arg) mgr():reportFlightCowStock(arg.unitName) end, { unitName = playerObj.unitName }, { order = 3 }) +end + +function CTLDFatCowManager:reportFlightCowStock(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(groupIdOf(t), ctld.tr("Your flight has no Fat Cow."), 10) + return + end + self:reportStock(cow.unitName, groupIdOf(t)) +end + +-- ── Mission-placed cows (fatCowAICows): the VMCT contract ────────────────────────────────────── +-- Each entry { group = "", escort = "" }. CTLD activates +-- the cow when the escorted flight is in the world (at start, or when its first unit is born). + +function CTLDFatCowManager:_activateMissionCow(entry) + local g = Group.getByName(entry.group) + local flight = Group.getByName(entry.escort) + if not (g and flight and flight:isExist()) then return false end + if self:aiCowOfFlight(entry.escort) then 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) 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/tests/ci/unit/fatcow_ai_spec.lua b/tests/ci/unit/fatcow_ai_spec.lua new file mode 100644 index 00000000..69d83943 --- /dev/null +++ b/tests/ci/unit/fatcow_ai_spec.lua @@ -0,0 +1,230 @@ +---@diagnostic disable +-- tests/ci/unit/fatcow_ai_spec.lua +-- FEAT-FAT-COW ticket 04: an AI cow follows a flight, lands next to its leader, deploys after the +-- delay, packs when the leader takes off, goes home when empty or released. +-- ============================================================ + +local HELLFIRE = "weapons.missiles.AGM_114K" + +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: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.getLogisticZoneAtPoint, 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.getLogisticZoneAtPoint = function() return inZone and {} or nil end + sm.playScene = function(_, u, model, params) + local scene = { _name = "Fat Cow FARP#1", _params = params } + params.farpName = "FatCow_FARP-1" + mgr:onSceneLaidOut(scene) + return scene + end + sm.packScene = function() return {} end + Airbase.getByName = function(n) if n == "FatCow_FARP-1" then return { getWarehouse = function() return w end } end end + local settings = { fatCowAIType = "CH-47Fbl1", fatCowAIFuelKg = 7200, fatCowDeployDelay = 60, + fatCowAIItems = { { item = HELLFIRE, count = 6 } }, fatCowAICows = {} } + 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.getLogisticZoneAtPoint, 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() + newGroup("Gunfighter", 7, "Gunfighter-1") + local leader = newUnit("Gunfighter-1", "Gunfighter", 1000, 2000) + mgr:requestAICow("Gunfighter-1") + runScheduled() + local cow = mgr:aiCowOfFlight("Gunfighter") + return leader, cow + end + + it("is requested on the ground in a logistic zone, spawned hot with fuel, and follows the flight", function() + local _, cow = flightWithCow() + assert.equals(1, #added) + assert.equals("CH-47Fbl1", added[1].units[1].type) + assert.equals("TakeOffGroundHot", added[1].route.points[1].type) + assert.is_true(added[1].units[1].payload.fuel > 0) + 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("is refused in the air, outside a logistic zone, and a second time for the same flight", function() + newGroup("Gunfighter", 7, "Gunfighter-1") + local leader = newUnit("Gunfighter-1", "Gunfighter") + leader.airborne = true + mgr:requestAICow("Gunfighter-1") + leader.airborne = false + inZone = false + mgr:requestAICow("Gunfighter-1") + assert.equals(0, #added) + inZone = true + mgr:requestAICow("Gunfighter-1") + runScheduled() + mgr:requestAICow("Gunfighter-1") + assert.equals(1, #added) + 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("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("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("Follow", lastTask("Cow Group").id) + end) +end) From ba33b1a53be812e2274d6e007a22413596f90c8b Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:32:47 +0100 Subject: [PATCH 15/36] feat(fat-cow): fatCow settings, catalogue 2.3.0 and translations Co-Authored-By: Claude Opus 5.5 --- docs/mission-maker/ctld-tools.fr.md | 2 +- docs/mission-maker/ctld-tools.md | 2 +- src/CTLD_config.yaml | 2 +- src/CTLD_config_schema.yaml | 51 +++ src/CTLD_fatCow.lua | 14 +- src/CTLD_i18n_en.lua | 32 +- src/CTLD_i18n_es.lua | 32 +- src/CTLD_i18n_fr.lua | 32 +- src/CTLD_i18n_ko.lua | 32 +- tests/ci/data/catalogue_shapes/2.3.0.json | 322 ++++++++++++++++++ tests/ci/data/config_defaults.json | 35 +- tools/ctld-tools/tests/test_catalog.py | 2 +- .../tests/test_completion_entries_web.py | 6 +- tools/ctld-tools/tests/test_oracle.py | 2 +- tools/ctld-tools/tests/test_web_app.py | 8 +- 15 files changed, 550 insertions(+), 24 deletions(-) create mode 100644 tests/ci/data/catalogue_shapes/2.3.0.json 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/src/CTLD_config.yaml b/src/CTLD_config.yaml index 2ddd919a..ec96c51b 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 diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index 842d7380..538015d4 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -454,6 +454,57 @@ 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: 'What a player cow can load: each crate has a desc, a weight (kg in the hold, and its catalogue key) and either fuelKg (jet fuel) or an item (a DCS warehouse item name) with its count. Default: an 800 gal ERFS tank of 2400 kg, 4 Hellfires, 300 rounds of 30 mm.' + fr: "Ce qu'une cow pilotée peut charger : chaque caisse a un desc, un poids (kg en soute, et sa clé dans le catalogue) et soit fuelKg (carburant jet), soit un item (nom d'article d'entrepôt DCS) avec son nombre. Par défaut : un réservoir ERFS de 800 gal de 2400 kg, 4 Hellfire, 300 obus de 30 mm." +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. +fatCowAIFuelKg: + group: fob + label: {en: AI Fat Cow fuel, fr: Carburant de la cow IA} + unit: kg + description: + en: Jet fuel an AI cow carries for its FARP. Default 7200 kg, three 800 gal ERFS tanks. + fr: Carburant jet qu'une cow IA emporte pour sa FARP. Par défaut 7200 kg, trois réservoirs ERFS de 800 gal. +fatCowAIItems: + group: fob + label: {en: AI Fat Cow ammunition, fr: Munitions de la cow IA} + description: + en: 'Ammunition an AI cow carries, each an item (DCS warehouse item name) and a count. Default: 6 Hellfires (weapons.missiles.AGM_114K).' + fr: "Munitions qu'une cow IA emporte, chacune un item (nom d'article d'entrepôt DCS) et un nombre. Par défaut : 6 Hellfire (weapons.missiles.AGM_114K)." +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) and the flight it escorts (escort). 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) et la patrouille qu'il escorte (escort). 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_fatCow.lua b/src/CTLD_fatCow.lua index a830ded6..3f877314 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -229,7 +229,7 @@ function CTLDFatCowManager:onSceneLaidOut(scene) 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))) + self:_notify(cow, ctld.tr("Fat Cow FARP open: %1", CTLDFatCow.describeStock(cow.stock))) end --- Tells the cow's audience (its own group when piloted, the escorted flight when AI). @@ -404,7 +404,7 @@ function CTLDFatCowManager:deployFromHold(unitName) trigger.action.outTextForGroup(gid, text, 10) return end - trigger.action.outTextForGroup(gid, ctld.tr("Deploying the Fat Cow FARP…"), 10) + 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. @@ -430,7 +430,7 @@ function CTLDFatCowManager:packIntoHold(unitName, quiet) 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) + trigger.action.outTextForGroup(gid, ctld.tr("Fat Cow FARP packed: %1 left.", CTLDFatCow.describeStock(cow.stock)), 10) end end @@ -526,7 +526,7 @@ local CRUISE_SPEED_MPS = 60 local CRUISE_ALT_M = 150 local function aiStockFromConfig() - local stock = { fuelKg = ctld.gs("fatCowAIFuelKg") or 0, items = {} } + local stock = { fuelKg = ctld.gs("fatCowAIFuelKg"), items = {} } for _, it in ipairs(ctld.gs("fatCowAIItems") or {}) do if it.item and it.count then stock.items[it.item] = it.count end end @@ -652,7 +652,7 @@ function CTLDFatCowManager:onLand(event) cow.notifyGroupId = flightGroupId(cow) mgr:deploy(u) end - end, nil, timer.getTime() + (ctld.gs("fatCowDeployDelay") or 60)) + end, nil, timer.getTime() + ctld.gs("fatCowDeployDelay")) end return end @@ -739,7 +739,7 @@ function CTLDFatCowManager:requestAICow(unitName) ctld.scheduler.schedule(function() mgr:addAICow(groupName, flight, home) end, nil, timer.getTime() + 1) trigger.action.outTextForGroup(gid, ctld.tr("Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later.", - ctld.gs("fatCowDeployDelay") or 60), 15) + ctld.gs("fatCowDeployDelay")), 15) end --- F10 "Release the Fat Cow": it packs and goes home. @@ -756,7 +756,7 @@ function CTLDFatCowManager:releaseAICow(unitName) end function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) - if (ctld.gs("fatCowAIType") or "") == "" then return end + if ctld.gs("fatCowAIType") == "" then return end menu:addSubMenu({ path[1] }, path[2], { order = 50 }) local mgr = CTLDFatCowManager.getInstance menu:addCommand(path, ctld.tr("Request a Fat Cow"), diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index a746843b..0a65e928 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.29" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -612,3 +612,33 @@ 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 30 mm (x300)"] = "Fat Cow 30 mm (x300)" +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 Hellfires (x4)"] = "Fat Cow Hellfires (x4)" +ctld.i18n["en"]["Fat Cow released: it goes home."] = "Fat Cow released: it goes home." +ctld.i18n["en"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later." +ctld.i18n["en"]["Fat Cow stock"] = "Fat Cow stock" +ctld.i18n["en"]["Fat Cow stock: %1"] = "Fat Cow stock: %1" +ctld.i18n["en"]["Land in a logistic zone to request a Fat Cow."] = "Land in a logistic zone to request a Fat Cow." +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"]["Release the Fat Cow"] = "Release the Fat Cow" +ctld.i18n["en"]["Request %1"] = "Request %1" +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." diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 1a9ce457..0baaa8f6 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.29" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -619,3 +619,33 @@ 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 30 mm (x300)"] = "Fat Cow 30 mm (x300)" +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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" +ctld.i18n["es"]["Fat Cow released: it goes home."] = "Fat Cow liberado: regresa a la base." +ctld.i18n["es"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow solicitado: sigue a tu escuadrilla, aterriza junto a tu líder y abre su FARP %1 s después." +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 in a logistic zone to request a Fat Cow."] = "Aterriza en una zona logística para solicitar un Fat Cow." +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"]["Release the Fat Cow"] = "Liberar el Fat Cow" +ctld.i18n["es"]["Request %1"] = "Solicitar %1" +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." diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index bb8aaf6f..00ce0505 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.29" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -644,3 +644,33 @@ 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 30 mm (x300)"] = "Fat Cow 30 mm (x300)" +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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" +ctld.i18n["fr"]["Fat Cow released: it goes home."] = "Fat Cow libérée : elle rentre." +ctld.i18n["fr"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow demandée : elle suit votre patrouille, se pose à côté de votre leader et ouvre sa FARP %1 s plus tard." +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 in a logistic zone to request a Fat Cow."] = "Posez-vous dans une zone logistique pour demander une Fat Cow." +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"]["Release the Fat Cow"] = "Libérer la Fat Cow" +ctld.i18n["fr"]["Request %1"] = "Demander %1" +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." diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 2b7cfb6c..562ff709 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.29" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -501,3 +501,33 @@ 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 30 mm (x300)"] = "Fat Cow 30mm (x300)" +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 Hellfires (x4)"] = "Fat Cow 헬파이어 (x4)" +ctld.i18n["ko"]["Fat Cow released: it goes home."] = "Fat Cow 해제: 귀환합니다." +ctld.i18n["ko"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow 요청됨: 편대를 따라가 편대장 옆에 착륙하고 %1초 후 FARP를 엽니다." +ctld.i18n["ko"]["Fat Cow stock"] = "Fat Cow 재고" +ctld.i18n["ko"]["Fat Cow stock: %1"] = "Fat Cow 재고: %1" +ctld.i18n["ko"]["Land in a logistic zone to request a Fat Cow."] = "Fat Cow를 요청하려면 보급 구역에 착륙하세요." +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"]["Release the Fat Cow"] = "Fat Cow 해제" +ctld.i18n["ko"]["Request %1"] = "%1 요청" +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가 없습니다." 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..294820aa --- /dev/null +++ b/tests/ci/data/catalogue_shapes/2.3.0.json @@ -0,0 +1,322 @@ +{ + "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", + "fatCowAIFuelKg", + "fatCowAIItems", + "fatCowAIType", + "fatCowCrates", + "fatCowDeployDelay", + "fatCowEnabled", + "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..f79daee5 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,39 @@ ], "farpTroopPickupRadius": 150, "fastRopeMaximumHeight": 18.28, + "fatCowAICows": [], + "fatCowAIFuelKg": 7200, + "fatCowAIItems": [ + { + "count": 6, + "item": "weapons.missiles.AGM_114K" + } + ], + "fatCowAIType": "CH-47Fbl1", + "fatCowCrates": [ + { + "desc": "Fat Cow fuel tank (800 gal)", + "fuelKg": 2400, + "weight": 2400 + }, + { + "count": 4, + "desc": "Fat Cow Hellfires (x4)", + "item": "weapons.missiles.AGM_114K", + "weight": 250 + }, + { + "count": 300, + "desc": "Fat Cow 30 mm (x300)", + "item": "weapons.shells.M230_HEDP M789", + "weight": 150 + } + ], + "fatCowDeployDelay": 60, + "fatCowEnabled": true, + "fatCowTypes": [ + "CH-47Fbl1" + ], "fieldExtractTroopWeight": 130, "fobCrateCollectionRadius": 750, "fobDestructionThreshold": 0.5, 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 From c90419123fa5211885d53f5560b67d3a79d5e46a Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:34:51 +0100 Subject: [PATCH 16/36] docs(fat-cow): pilot and mission-maker pages, CHANGELOG; correct the countryside warehouse comment Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++++++ docs/mission-maker/configuration.fr.md | 1 + docs/mission-maker/configuration.md | 1 + docs/mission-maker/fat-cow.fr.md | 79 ++++++++++++++++++++++++ docs/mission-maker/fat-cow.md | 79 ++++++++++++++++++++++++ docs/pilot/fat-cow.fr.md | 51 +++++++++++++++ docs/pilot/fat-cow.md | 51 +++++++++++++++ mkdocs.yml | 2 + src/scenes/CTLD_countrysideFarpScene.lua | 6 +- 9 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 docs/mission-maker/fat-cow.fr.md create mode 100644 docs/mission-maker/fat-cow.md create mode 100644 docs/pilot/fat-cow.fr.md create mode 100644 docs/pilot/fat-cow.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a616533d..72741769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ 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** requests Fat Cow crates in a logistic zone (an 800 gal ERFS tank of 2 400 kg of jet fuel, 4 Hellfires, 300 rounds of 30 mm), loads them into its hold, lands, and **Deploy the Fat Cow FARP** from F10 → CTLD → Fat Cow. + 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. +- Any other player can **Request a Fat Cow** while on the ground in a friendly logistic zone: an AI CH-47 follows the flight, lands next to its leader, opens its FARP 60 s later (`fatCowDeployDelay`), packs when the leader takes off, and flies home to despawn once empty or on **Release the Fat Cow**. + By default it carries 7 200 kg of fuel and 6 Hellfires (`fatCowAIFuelKg`, `fatCowAIItems`). +- A cow placed in the mission (a late-activated helicopter group) escorts a named flight when declared in `fatCowAICows` (`{ group, escort }`): the contract the VEAF Mission Creation Tools rely on. +- The FARP is an Invisible FARP, a FARP Fuel Depot, a few crates and three soldiers: no vehicle, no tent, no ammo dump. + 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). + 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 whenever a player joins, with what the server holds: whether DCS shares a scripted warehouse with clients was not measured. +- New settings `fatCowEnabled`, `fatCowTypes`, `fatCowCrates`, `fatCowDeployDelay`, `fatCowAIType`, `fatCowAIFuelKg`, `fatCowAIItems`, `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/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/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md new file mode 100644 index 00000000..f65e7ca6 --- /dev/null +++ b/docs/mission-maker/fat-cow.fr.md @@ -0,0 +1,79 @@ +# 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 | 15 à 18 m derrière | décor, jamais chargeables | +| 3 soldats | 20 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). + +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 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. + +## 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, 30 mm | Ce qu'une cow pilotée peut charger (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. | +| `fatCowAIFuelKg` | `7200` | Carburant jet emporté par une cow IA (trois réservoirs ERFS de 800 gal). | +| `fatCowAIItems` | 6 × `weapons.missiles.AGM_114K` | Munitions emportées par une cow IA. | +| `fatCowAICows` | `[]` | Cows placées dans la mission (voir plus bas). | + +### Caisses cow + +Chaque entrée de `fatCowCrates` est une caisse qu'une cow pilotée peut demander : + +```yaml +fatCowCrates: +- desc: Fat Cow fuel tank (800 gal) + weight: 2400 # kg en soute ; aussi la clé de la caisse dans le catalogue + fuelKg: 2400 # carburant jet contenu +- desc: Fat Cow Hellfires (x4) + weight: 250 + item: weapons.missiles.AGM_114K # nom d'article d'entrepôt DCS + count: 4 +``` + +Une entrée a soit `fuelKg`, soit `item` et `count`. +`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. +L'entrée 30 mm (`weapons.shells.M230_HEDP M789`) est le nom que DCS donne aux obus de l'Apache, pas encore vérifié comme article d'entrepôt. + +Les caisses cow n'apparaissent ni dans **Request Equipment** ni dans **Unpack Crate** : on les demande depuis le menu Fat Cow, et on les déploie depuis la soute. + +## Placer une cow IA dans une mission + +Une patrouille peut demander une cow IA dans une zone logistique en cours de mission. +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 +``` + +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. +La cow se comporte ensuite comme une cow demandée : elle suit, se pose à côté du leader, se déploie après `fatCowDeployDelay`, se replie quand le leader décolle, et rentre à son point de départ une fois vide. + +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 }`. +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..c5fd7c51 --- /dev/null +++ b/docs/mission-maker/fat-cow.md @@ -0,0 +1,79 @@ +# 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 | 15–18 m behind | dressing, never loadable | +| 3 soldiers | 20 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). + +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, with the quantities the server holds: whether DCS shares a scripted warehouse with clients was not measured. + +## 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, 30 mm | What a player cow can load (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**. | +| `fatCowAIFuelKg` | `7200` | Jet fuel an AI cow carries (three 800 gal ERFS tanks). | +| `fatCowAIItems` | 6 × `weapons.missiles.AGM_114K` | Ammunition an AI cow carries. | +| `fatCowAICows` | `[]` | Cows placed in the mission (see below). | + +### Cow crates + +Each `fatCowCrates` entry is one crate a player cow can request: + +```yaml +fatCowCrates: +- desc: Fat Cow fuel tank (800 gal) + weight: 2400 # kg in the hold; also the crate's catalogue key + fuelKg: 2400 # jet fuel it holds +- desc: Fat Cow Hellfires (x4) + weight: 250 + item: weapons.missiles.AGM_114K # DCS warehouse item name + count: 4 +``` + +An entry has either `fuelKg`, or `item` and `count`. +`item` is the name DCS gives the weapon in a warehouse: `weapons.missiles.AGM_114K` was checked in game for the Hellfire. +The 30 mm entry (`weapons.shells.M230_HEDP M789`) is the name DCS gives the Apache's rounds, not yet checked as a warehouse item. + +Cow crates never show in **Request Equipment** nor in **Unpack Crate**: they are requested from the Fat Cow menu, and deployed from the hold. + +## Placing an AI cow in a mission + +A flight can request an AI cow in a logistic zone during the mission. +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 +``` + +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. +The cow then behaves as a requested one: it follows, lands next to the leader, deploys after `fatCowDeployDelay`, packs when the leader takes off, and flies back to where it started once empty. + +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 }`. +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..90bcdf74 --- /dev/null +++ b/docs/pilot/fat-cow.fr.md @@ -0,0 +1,51 @@ +# 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. + +## Piloter une cow (CH-47F) + +Tout se trouve dans **F10 → CTLD → Fat Cow**. + +1. Posez-vous dans une zone logistique. +2. Demandez ce que vous voulez emporter, une caisse à la fois : + - **Réservoir Fat Cow (800 gal)** : 2 400 kg de carburant jet, et 2 400 kg dans votre soute ; + - **Hellfire Fat Cow (x4)** ; + - **Fat Cow 30 mm (x300)**. +3. Chargez les caisses en soute, comme n'importe quelle caisse CTLD. +4. Volez jusqu'aux hélicoptères d'attaque qui ont besoin de vous, et posez-vous. +5. **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. +6. 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. +7. **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. + +!!! 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 voit **F10 → CTLD → Fat Cow → Demander une Fat Cow**. + +1. Posez-vous dans une zone logistique amie, et demandez-la là : une cow n'est jamais envoyée sur le terrain. +2. Un CH-47 démarre à 80 m derrière vous et suit votre patrouille. +3. Quand votre leader se pose, la cow se pose à côté de lui et ouvre sa FARP une minute plus tard. +4. Quand votre leader décolle, la cow replie sa FARP et vous suit à nouveau. +5. Une fois vide, ou quand vous utilisez **Libérer la Fat Cow**, elle rentre et disparaît. + +Par défaut, une cow IA emporte 7 200 kg de carburant (trois réservoirs de 800 gal) et 6 Hellfire. +Une cow par patrouille. + +!!! tip "Posez-vous à découvert" + La cow se pose à environ 120 m à gauche de votre leader. + Choisissez un terrain dégagé : un hélicoptère envoyé se poser en lisière de forêt y reste en stationnaire sans jamais toucher le sol. diff --git a/docs/pilot/fat-cow.md b/docs/pilot/fat-cow.md new file mode 100644 index 00000000..d5eeb30f --- /dev/null +++ b/docs/pilot/fat-cow.md @@ -0,0 +1,51 @@ +# 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. + +## Flying a cow (CH-47F) + +Everything is in **F10 → CTLD → Fat Cow**. + +1. Land in a logistic zone. +2. Request what you want to carry, one crate at a time: + - **Fat Cow fuel tank (800 gal)**: 2 400 kg of jet fuel, and 2 400 kg in your hold; + - **Fat Cow Hellfires (x4)**; + - **Fat Cow 30 mm (x300)**. +3. Load the crates into the hold, like any CTLD crate. +4. Fly to where the attack helicopters need you, and land. +5. **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. +6. The other helicopters land next to you and rearm and refuel through their ground crew menu, as on any FARP. +7. **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. + +!!! 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 sees **F10 → CTLD → Fat Cow → Request a Fat Cow**. + +1. Land in a friendly logistic zone, and request it there: a cow is never sent to you in the field. +2. A CH-47 starts up 80 m behind you and follows your flight. +3. When your leader lands, the cow lands next to your leader, and opens its FARP one minute later. +4. When your leader takes off, the cow packs and follows again. +5. Once it is empty, or when you use **Release the Fat Cow**, it flies home and disappears. + +By default an AI cow carries 7 200 kg of fuel (three 800 gal tanks) and 6 Hellfires. +One cow per flight. + +!!! tip "Land in the open" + The cow lands about 120 m to the left of your leader. + Pick open ground: a helicopter sent to land at the edge of a forest hovers there and never touches down. 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/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 = { From 13f4eec7c873f7b4cbb62bf60676e3d12922a26b Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:35:12 +0100 Subject: [PATCH 17/36] chore(fat-cow): declare the Fat Cow globals to luacheck Co-Authored-By: Claude Opus 5.5 --- .luacheckrc | 2 ++ 1 file changed, 2 insertions(+) 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 From 0c12473490d565d646feb6db76a30bb96981e4a9 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:36:19 +0100 Subject: [PATCH 18/36] fix(fat-cow): only the flight's leader moves the cow; dressing clear of the rear rotor Co-Authored-By: Claude Opus 5.5 --- docs/mission-maker/fat-cow.fr.md | 4 ++-- docs/mission-maker/fat-cow.md | 4 ++-- src/CTLD_fatCow.lua | 20 ++++++++++++++------ src/scenes/CTLD_fatCowScene.lua | 14 +++++++------- tests/ci/unit/fatcow_ai_spec.lua | 13 +++++++++++++ 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/docs/mission-maker/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md index f65e7ca6..f7d0a93b 100644 --- a/docs/mission-maker/fat-cow.fr.md +++ b/docs/mission-maker/fat-cow.fr.md @@ -11,8 +11,8 @@ Quand une cow se déploie, CTLD joue la scène **Fat Cow FARP** à la cow : |---|---|---| | 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 | 15 à 18 m derrière | décor, jamais chargeables | -| 3 soldats | 20 m derrière, à gauche | en attente | +| 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). diff --git a/docs/mission-maker/fat-cow.md b/docs/mission-maker/fat-cow.md index c5fd7c51..87284482 100644 --- a/docs/mission-maker/fat-cow.md +++ b/docs/mission-maker/fat-cow.md @@ -11,8 +11,8 @@ When a cow deploys, CTLD plays the **Fat Cow FARP** scene at the cow: |---|---|---| | 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 | 15–18 m behind | dressing, never loadable | -| 3 soldiers | 20 m behind, to the left | standing by | +| 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). diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 3f877314..919551d6 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -623,13 +623,23 @@ function CTLDFatCowManager:_cancelDeploy(cow) 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 + --- 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 escorted flight lands → the cow lands next to it; the cow lands → it deploys +--- S_EVENT_LAND: the escorted flight's leader lands → the cow lands next to it; the cow lands → it deploys -- after fatCowDeployDelay, or despawns when it was going home. function CTLDFatCowManager:onLand(event) local unit = event and event.initiator @@ -657,20 +667,18 @@ function CTLDFatCowManager:onLand(event) return end - local okG, group = pcall(unit.getGroup, unit) - local flight = okG and group and group:getName() + local flight = flightLedBy(unit) local escort = flight and self:aiCowOfFlight(flight) if escort and escort.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING then self:_landNextTo(escort, unit) end end ---- The escorted flight takes off → the cow packs and follows again, or goes home if empty. +--- The escorted flight's leader takes off → the cow packs and follows again, or goes home if empty. function CTLDFatCowManager:_onAITakeoff(name) local unit = Unit.getByName(name) if not unit then return end - local okG, group = pcall(unit.getGroup, unit) - local flight = okG and group and group:getName() + local flight = flightLedBy(unit) local cow = flight and self:aiCowOfFlight(flight) if not cow or cow.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING then return end self:_cancelDeploy(cow) diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua index e8ff0601..15cf1a16 100644 --- a/src/scenes/CTLD_fatCowScene.lua +++ b/src/scenes/CTLD_fatCowScene.lua @@ -9,9 +9,9 @@ -- 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 -- FARP Fuel Depot — 25 m behind the ramp (180°): the supplier --- Ammo crates — 15 m / 160° and 15 m / 200°: dressing (never loadable: "CTLD_" name prefix) --- Barrels — 18 m / 145°: dressing --- 3 infantry — 20 m / 215°, standing by +-- 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. @@ -49,21 +49,21 @@ fatCowScene.steps = { -- Steps 3-5: dressing. { - polar = { distance = 15, angle = 160 }, + polar = { distance = 20, angle = 135 }, delayAfterPreviousStep = 2, relativeHeadingInDegrees = 0, relativeAltitudeInMeters = 0, registryKey = "FatCow_Ammo", }, { - polar = { distance = 15, angle = 200 }, + polar = { distance = 20, angle = 130 }, delayAfterPreviousStep = 0, relativeHeadingInDegrees = 30, relativeAltitudeInMeters = 0, registryKey = "FatCow_Ammo", }, { - polar = { distance = 18, angle = 145 }, + polar = { distance = 21, angle = 124 }, delayAfterPreviousStep = 0, relativeHeadingInDegrees = 0, relativeAltitudeInMeters = 0, @@ -72,7 +72,7 @@ fatCowScene.steps = { -- Step 6: the crew standing by. { - polar = { distance = 20, angle = 215 }, + polar = { distance = 25, angle = 225 }, delayAfterPreviousStep = 0, relativeHeadingInDegrees = 0, relativeAltitudeInMeters = 0, diff --git a/tests/ci/unit/fatcow_ai_spec.lua b/tests/ci/unit/fatcow_ai_spec.lua index 69d83943..e38bc96e 100644 --- a/tests/ci/unit/fatcow_ai_spec.lua +++ b/tests/ci/unit/fatcow_ai_spec.lua @@ -177,6 +177,19 @@ describe("AI Fat Cow", function() 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 }) From a4eb21ba5e397dad3a98de1da7256997c91d4d04 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:38:27 +0100 Subject: [PATCH 19/36] fix(fat-cow): dressing outside the object registry, the scene tested end to end The ammo crates and barrels shared their DCS types with existing registry entries, which made findByDCSType's answer depend on spec order. They are spawned by a scene step, named CTLD_ so they are never loadable crates; the FARP reuses the Invisible_FARP entry. Co-Authored-By: Claude Opus 5.5 --- src/scenes/CTLD_fatCowScene.lua | 83 ++++++++++-------------------- tests/ci/unit/fatcow_core_spec.lua | 57 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 55 deletions(-) diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua index 15cf1a16..2e329ebe 100644 --- a/src/scenes/CTLD_fatCowScene.lua +++ b/src/scenes/CTLD_fatCowScene.lua @@ -21,6 +21,13 @@ 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. Its name is what the warehouse is looked up by. @@ -29,7 +36,7 @@ fatCowScene.steps = { delayAfterPreviousStep = 0, relativeHeadingInDegrees = 0, relativeAltitudeInMeters = 0, - registryKey = "FatCow_FARP", + registryKey = "Invisible_FARP", critical = true, func = function(ctx) ctx.scene._params.farpName = ctx.spawnedObj:getName() @@ -47,30 +54,28 @@ fatCowScene.steps = { critical = true, }, - -- Steps 3-5: dressing. - { - polar = { distance = 20, angle = 135 }, - delayAfterPreviousStep = 2, - relativeHeadingInDegrees = 0, - relativeAltitudeInMeters = 0, - registryKey = "FatCow_Ammo", - }, + -- 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. { - polar = { distance = 20, angle = 130 }, - delayAfterPreviousStep = 0, - relativeHeadingInDegrees = 30, - relativeAltitudeInMeters = 0, - registryKey = "FatCow_Ammo", - }, - { - polar = { distance = 21, angle = 124 }, - delayAfterPreviousStep = 0, - relativeHeadingInDegrees = 0, - relativeAltitudeInMeters = 0, - registryKey = "FatCow_Barrels", + 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 6: the crew standing by. + -- Step 4: the crew standing by. { polar = { distance = 25, angle = 225 }, delayAfterPreviousStep = 0, @@ -79,7 +84,7 @@ fatCowScene.steps = { registryKey = "FatCow_Crew", }, - -- Step 7: stock the warehouse and tell the manager the FARP is open. + -- Step 5: stock the warehouse and tell the manager the FARP is open. { delayAfterPreviousStep = 0, func = function(ctx) @@ -93,38 +98,6 @@ fatCowScene.steps = { -- Registry entries required by this scene (registerIfAbsent: shared keys are left as declared). -- ==================================================================================================== -CTLDObjectRegistry.registerIfAbsent("FatCow_FARP", { - groupType = "STATIC", - namePrefix = "FatCow_FARP", - type = "Invisible FARP", - shape_name = "invisiblefarp", - category = "Heliports", - heliport_frequency = "127.5", - heliport_callsign_id = 1, - heliport_modulation = 0, - rate = 100, -}) - --- Dressing cargo statics. The "CTLD_" prefix keeps CTLDCrateManager:onBirth from registering them as --- loadable crates (it registers every other cargo static that appears). -CTLDObjectRegistry.registerIfAbsent("FatCow_Ammo", { - groupType = "STATIC", - namePrefix = "CTLD_FatCow_Ammo", - type = "ammo_cargo", - category = "Cargos", - shape_name = "ammo_box_cargo", - rate = 1, -}) - -CTLDObjectRegistry.registerIfAbsent("FatCow_Barrels", { - groupType = "STATIC", - namePrefix = "CTLD_FatCow_Barrels", - type = "barrels_cargo", - category = "Cargos", - shape_name = "barrels_cargo", - rate = 100, -}) - -- Shape from DCS's db.Units.lua ("FARP Fuel Depot", ShapeName "GSM Rus"). CTLDObjectRegistry.registerIfAbsent("FatCow_Fuel_Depot", { groupType = "STATIC", diff --git a/tests/ci/unit/fatcow_core_spec.lua b/tests/ci/unit/fatcow_core_spec.lua index cf461f84..1569ec44 100644 --- a/tests/ci/unit/fatcow_core_spec.lua +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -82,6 +82,63 @@ describe("CTLDFatCow stock", function() 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 = {}, {}, nil + local function obj(name) return { getName = function() return name end, isExist = function() return true end } end + CTLDObjectRegistry.spawnObject = function(key, coa, cid) + local types = CTLDTypeCollector.typesOfDescriptor(CTLDObjectRegistry.get(key)) + for _, t in ipairs(types) do spawned[#spawned + 1] = t end + return obj(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, + } + CTLDSceneManager.getInstance():playScene(unit, "Fat Cow FARP", {}) + 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("Invisible_FARP-1", laidOut._params.farpName) + end) +end) + -- ── Manager: deploy, pack, re-sync ───────────────────────────────────────────────────────────── describe("CTLDFatCowManager deploy / pack", function() From 3045a30830b6382a853a53e7f3670dc29ff52137 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:42:38 +0100 Subject: [PATCH 20/36] fix(fat-cow): review findings - packing while the scene lays out aborts it (no orphan objects) - an AI cow whose flight is gone packs and goes home - the Land task keeps the cow down for a day, not an hour - cow crate descriptors go through CTLDCrateManager:registerDescriptor - a part-used crate respawns on the ground at what it still weighs - packing gives every crate the FARP opened with its share back - a late-activated escort activates the mission cow only once it is active Co-Authored-By: Claude Opus 5.5 --- src/CTLD_crate.lua | 21 +++++- src/CTLD_fatCow.lua | 92 ++++++++++++++++++--------- tests/ci/unit/fatcow_ai_spec.lua | 28 ++++++++ tests/ci/unit/fatcow_core_spec.lua | 13 ++++ tests/ci/unit/fatcow_piloted_spec.lua | 19 ++++++ 5 files changed, 141 insertions(+), 32 deletions(-) diff --git a/src/CTLD_crate.lua b/src/CTLD_crate.lua index 6c36997a..a30bc7f0 100644 --- a/src/CTLD_crate.lua +++ b/src/CTLD_crate.lua @@ -426,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). @@ -1975,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 diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 919551d6..bb3d66d9 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -247,6 +247,10 @@ function CTLDFatCowManager:pack(unitName) local w = warehouseOf(cow.farpName) if w then cow:readWarehouse(w) 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 @@ -285,6 +289,7 @@ function CTLDFatCowManager:start() 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() ctld.utils.log("INFO", "CTLDFatCowManager: started, %d cow crate type(s)", #self._crateDescs) end @@ -301,23 +306,14 @@ function CTLDFatCowManager:_injectCrateDescriptors() ctld.utils.log("WARN", "CTLDFatCowManager: fatCowCrates entry '%s' ignored: it needs desc, weight, and fuelKg or item + count", tostring(def.desc)) else - local w = def.weight - while cm._weightIndex[w] do - w = math.floor(w * 100 + 1.5) / 100 - end - if w ~= def.weight then - ctld.utils.log("WARN", "CTLDFatCowManager: crate '%s' weight %.2f taken — reassigned to %.2f", - def.desc, def.weight, w) - end - local d = { - weight = w, + local d = cm:registerDescriptor({ + weight = def.weight, desc = ctld.tr(def.desc), unit = "FatCow:" .. def.desc, cratesRequired = 1, showSets = false, fatCow = content, - } - cm._weightIndex[w] = d + }) self._crateDescs[#self._crateDescs + 1] = d end end @@ -334,10 +330,6 @@ end -- Piloted cow (ticket 03): F10 > CTLD > Fat Cow, for the types listed in fatCowTypes -- ==================================================================================================== -local function groupIdOf(unit) - return ctld.utils.getGroupId(unit) -end - --- The cow crates in a unit's hold. local function cowCratesOnboard(unitName) local out = {} @@ -350,7 +342,7 @@ end function CTLDFatCowManager:requestCrate(unitName, weight) local t = Unit.getByName(unitName) if not (t and t:isExist()) then return end - local gid = groupIdOf(t) + 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 @@ -378,7 +370,7 @@ end function CTLDFatCowManager:deployFromHold(unitName) local t = Unit.getByName(unitName) if not (t and t:isExist()) then return end - local gid = groupIdOf(t) + 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) @@ -412,19 +404,21 @@ end function CTLDFatCowManager:packIntoHold(unitName, quiet) local cow = self._cows[unitName] local t = Unit.getByName(unitName) - local gid = t and t:isExist() and groupIdOf(t) + 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 stillOnboard = {} + local stillThere = {} for _, crate in ipairs(cow.crates) do - if crate:isLoaded() and cm:isCarriedBy(crate, unitName) then stillOnboard[#stillOnboard + 1] = crate end + if cm.crates[crate.crateName] == crate then stillThere[#stillThere + 1] = crate end end - for _, crate in ipairs(CTLDFatCow.refillCrates(cow.stock, stillOnboard)) do + for _, crate in ipairs(CTLDFatCow.refillCrates(cow.stock, stillThere)) do cm:destroyCrate(crate.crateName) end ctld.utils.updateTransportWeight(unitName) @@ -447,7 +441,7 @@ function CTLDFatCowManager:reportStock(unitName, toGroupId) else stock = CTLDFatCow.stockOfCrates(cowCratesOnboard(unitName)) end - trigger.action.outTextForGroup(toGroupId or groupIdOf(t), ctld.tr("Fat Cow stock: %1", CTLDFatCow.describeStock(stock)), 15) + 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). @@ -462,13 +456,36 @@ function CTLDFatCowManager:onTakeoff(event) self:_onAITakeoff(name) end ---- S_EVENT_DEAD / S_EVENT_CRASH: a dead cow's FARP goes with it. +--- 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 self:removeCow(name) self:_forgetAICow(name) 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 ──────────────────────────────────────────────────────────────────────────────── @@ -524,6 +541,9 @@ local FOLLOW_OFFSET = { x = -200, y = 60, z = 150 } -- behind, above and to local LAND_OFFSET_M = 120 -- landing point, to the leader's left local CRUISE_SPEED_MPS = 60 local CRUISE_ALT_M = 150 +-- 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 local function aiStockFromConfig() local stock = { fuelKg = ctld.gs("fatCowAIFuelKg"), items = {} } @@ -555,7 +575,7 @@ local function landMission(point) 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 = 3600 }, + params = { point = { x = point.x, y = point.z }, durationFlag = true, duration = LAND_DURATION_S }, } } } }, } } } }, } @@ -701,7 +721,7 @@ end function CTLDFatCowManager:requestAICow(unitName) local t = Unit.getByName(unitName) if not (t and t:isExist()) then return end - local gid = groupIdOf(t) + 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) @@ -756,11 +776,11 @@ function CTLDFatCowManager:releaseAICow(unitName) if not (t and t:isExist()) then return end local cow = self:aiCowOfFlight(t:getGroup():getName()) if not cow then - trigger.action.outTextForGroup(groupIdOf(t), ctld.tr("Your flight has no Fat Cow."), 10) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Your flight has no Fat Cow."), 10) return end self:_returnHome(cow) - trigger.action.outTextForGroup(groupIdOf(t), ctld.tr("Fat Cow released: it goes home."), 10) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow released: it goes home."), 10) end function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) @@ -780,20 +800,30 @@ function CTLDFatCowManager:reportFlightCowStock(unitName) if not (t and t:isExist()) then return end local cow = self:aiCowOfFlight(t:getGroup():getName()) if not cow then - trigger.action.outTextForGroup(groupIdOf(t), ctld.tr("Your flight has no Fat Cow."), 10) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Your flight has no Fat Cow."), 10) return end - self:reportStock(cow.unitName, groupIdOf(t)) + self:reportStock(cow.unitName, ctld.utils.getGroupId(t)) end -- ── Mission-placed cows (fatCowAICows): the VMCT contract ────────────────────────────────────── -- Each entry { group = "", escort = "" }. 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 flight and flight:isExist()) then return false end + if not (g and inWorld(flight)) then return false end if self:aiCowOfFlight(entry.escort) then return true end pcall(g.activate, g) local u = g:getUnit(1) diff --git a/tests/ci/unit/fatcow_ai_spec.lua b/tests/ci/unit/fatcow_ai_spec.lua index e38bc96e..677e9791 100644 --- a/tests/ci/unit/fatcow_ai_spec.lua +++ b/tests/ci/unit/fatcow_ai_spec.lua @@ -225,6 +225,34 @@ describe("AI Fat Cow", function() 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") diff --git a/tests/ci/unit/fatcow_core_spec.lua b/tests/ci/unit/fatcow_core_spec.lua index 1569ec44..8a8b6d7e 100644 --- a/tests/ci/unit/fatcow_core_spec.lua +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -233,6 +233,19 @@ describe("CTLDFatCowManager deploy / pack", function() assert.is_nil(cow.scene) 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")) diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua index 4f3f51f9..b33f61b9 100644 --- a/tests/ci/unit/fatcow_piloted_spec.lua +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -155,6 +155,16 @@ describe("Piloted cow commands", function() 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") @@ -210,6 +220,15 @@ describe("CTLDCrateManager:getLoadedCrateWeight with a part-used crate", functio 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) From fd16eb849e8181f41cbea4824ede68f0bcf24a1e Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:43:14 +0100 Subject: [PATCH 21/36] docs(fat-cow): backlog status and the final VMCT contract Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 21 ++++++++++--------- .../tickets/02-core-stock-and-scene.md | 6 +++--- .../FEAT-FAT-COW/tickets/03-piloted-cow.md | 4 ++-- .backlog/FEAT-FAT-COW/tickets/04-ai-cow.md | 4 ++-- .../tickets/05-config-docs-release.md | 4 ++-- .../FEAT-FAT-COW/tickets/06-live-check.md | 4 +++- .backlog/README.md | 2 +- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index c81c6f33..79e69e6e 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -1,6 +1,6 @@ # 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. Implementation in progress (Zip, 2026-10-26: "implémente"). +**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"); ticket 06, the live check, 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. @@ -38,23 +38,24 @@ ED announced a native Fat Cow on 2024-04-19 and again on 2024-08-23; it is absen - **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 (draft, fixed when the AI ticket is written) +## Contract for VMCT -VMCT will add a CLI + MCP tool that places an AI cow escorting a named flight in a mission. It depends on how this lot declares a cow, so the contract is part of the lot's deliverable and lands in `docs/mission-maker/` (EN + FR): +Documented for mission makers in `docs/mission-maker/fat-cow.md` (EN + FR). -- **Config keys** (draft): `fatCow.enabled`, `fatCow.aiTemplate` (the CH-47 group CTLD clones for a requested cow), `fatCow.deployDelay` (60 s), `fatCow.aiStock` (`fuelKg` 7 200, `items` with 6 Hellfires), `fatCow.crate` (fuel per crate 2 400 kg), `fatCow.ammoCrates`. -- **A mission-placed AI cow** is recognised by an explicit config entry (`fatCow.aiCows = { { group = "", escort = "" } }`), not by parsing its name: the engine does not parse names for config-heavy features (see `CONTEXT.md`, "Config-referenced zone"). A tool-only naming convention for VMCT to pre-fill the entry stays possible. +- **A mission-placed AI cow** is a **late-activated helicopter group** declared in `fatCowAICows` with the flight it escorts: `fatCowAICows = [{ group: "", escort: "" }]`. 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`), `fatCowDeployDelay` (60 s), `fatCowAIType` (`CH-47Fbl1`; empty disables the request), `fatCowAIFuelKg` (7 200), `fatCowAIItems` (6 × `weapons.missiles.AGM_114K`), `fatCowAICows`. 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) | 🔄 in-progress | -| 03 | [Piloted cow: cow crates in a CH-47F and the F10 commands](tickets/03-piloted-cow.md) | ⬜ ready | -| 04 | [AI cow: requested by a flight, escorts it, serves it, goes home](tickets/04-ai-cow.md) | ⬜ ready | -| 05 | [Config, VMCT contract doc, CHANGELOG and release notes](tickets/05-config-docs-release.md) | ⬜ ready | -| 06 | [Live check in DCS](tickets/06-live-check.md) | ⬜ ready (HITL) | +| 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 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 index 4630d96f..bf71f541 100644 --- a/.backlog/FEAT-FAT-COW/tickets/02-core-stock-and-scene.md +++ b/.backlog/FEAT-FAT-COW/tickets/02-core-stock-and-scene.md @@ -1,6 +1,6 @@ # 02 — Core: a cow's stock, its scene, deploy and pack -**Status:** 🔄 in-progress · **Type:** AFK +**Status:** ✅ done · **Type:** AFK ## Parent @@ -18,5 +18,5 @@ ## Acceptance criteria -- [ ] 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`. -- [ ] Scene assets pass the design-time asset gate. +- [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 index 3986d75d..db7cce7a 100644 --- a/.backlog/FEAT-FAT-COW/tickets/03-piloted-cow.md +++ b/.backlog/FEAT-FAT-COW/tickets/03-piloted-cow.md @@ -1,6 +1,6 @@ # 03 — Piloted cow: cow crates in a CH-47F and the F10 commands -**Status:** ⬜ ready · **Type:** AFK +**Status:** ✅ done · **Type:** AFK ## Parent @@ -18,4 +18,4 @@ ## Acceptance criteria -- [ ] 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. +- [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 index 8f4aaf48..782c32cf 100644 --- a/.backlog/FEAT-FAT-COW/tickets/04-ai-cow.md +++ b/.backlog/FEAT-FAT-COW/tickets/04-ai-cow.md @@ -1,6 +1,6 @@ # 04 — AI cow: requested by a flight, escorts it, serves it, goes home -**Status:** ⬜ ready · **Type:** AFK +**Status:** ✅ done · **Type:** AFK ## Parent @@ -17,4 +17,4 @@ ## Acceptance criteria -- [ ] 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. +- [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 index e6335948..b72928d4 100644 --- a/.backlog/FEAT-FAT-COW/tickets/05-config-docs-release.md +++ b/.backlog/FEAT-FAT-COW/tickets/05-config-docs-release.md @@ -1,6 +1,6 @@ # 05 — Config, VMCT contract doc, CHANGELOG and release notes -**Status:** ⬜ ready · **Type:** AFK +**Status:** ✅ done · **Type:** AFK ## Parent @@ -14,4 +14,4 @@ ## Acceptance criteria -- [ ] `test_catalogue_shape.py` and `config_spec.lua` green; docs build; CHANGELOG guard green. +- [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 index af3ab838..95791825 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -1,6 +1,6 @@ # 06 — Live check in DCS -**Status:** ⬜ ready · **Type:** HITL (live DCS, Zip) +**Status:** 🧑 waiting-human · **Type:** HITL (live DCS, Zip) ## Parent @@ -11,4 +11,6 @@ - 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). diff --git a/.backlog/README.md b/.backlog/README.md index c77b9636..18cde8b3 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +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) | waiting-human — 01 [in-game spike](FEAT-FAT-COW/tickets/01-spike-runtime-farp.md) phase 1 done (serves, finite stock enforced, the FARP Fuel Depot static alone supplies, no vehicle), phase 2 (MP client) to run; 02+ next | 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` | +| [`FEAT-FAT-COW`](FEAT-FAT-COW/PRD.md) | in-progress — 01 spike ✅ (a runtime Invisible FARP serves, enforces a finite stock, and needs only a FARP Fuel Depot as supplier), 02-05 ✅ implemented, 06 waiting-human (live check, Zip) | 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. | From d8da3839cbf28349dd76f6ce84f72192498b3d23 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 10:49:47 +0100 Subject: [PATCH 22/36] docs(fat-cow): live check kit and procedure (ticket 06) Co-Authored-By: Claude Opus 5.5 --- .../FEAT-FAT-COW/tickets/06-live-check.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index 95791825..63a72289 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -14,3 +14,60 @@ - 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. + +## 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 Fat Cow fuel tank (800 gal)**. 📸 + Then **Request Fat Cow Hellfires (x4)**. +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, AGM_114K x4. 📸 +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 CH-47 appears 80 m behind you. +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** → **Release the Fat Cow**: it flies back to where it appeared and disappears. +7. Quit. + +## 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. | From aa753b7dd5ca41d5aa81333798f68ad47fde40b0 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 13:12:19 +0100 Subject: [PATCH 23/36] docs(fat-cow): server variant of the live check (Syria Open Training test build) Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/tickets/06-live-check.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index 63a72289..83dcb864 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -27,6 +27,10 @@ Rebuild it after any `src/` change, with the VMCT `veaf-mission-editor` actions 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: mp\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). + ## Procedure **Print Screen** where marked 📸. Nothing to copy: `dcs.log` and the screenshots are read afterwards. From 1e0d6082b3cac89027c2874d59cc2dce3ce29785 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 13:12:36 +0100 Subject: [PATCH 24/36] docs(fat-cow): fix a path in the server variant note Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/tickets/06-live-check.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index 83dcb864..2c2d690b 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -29,7 +29,7 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo ### 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: mp\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). +`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). ## Procedure From b6cc2c6cd4f5a3a92cade9b808538e43acfc0590 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 16:50:39 +0100 Subject: [PATCH 25/36] feat(fat-cow): a player flight commands its cow from F10; request from anywhere Land here, Take off, Hold here, Join us: nothing moves on its own for a player flight (the cow left whenever the leader hopped onto its FARP). An AI flight's cow keeps landing and taking off with its leader. The cow comes airborne from the nearest friendly logistic zone on land, with an estimated joining time. Fat Cow status (position, ETA, stock, also before landing) and FARP status (the Fat Cow FARP you are at). Co-Authored-By: Claude Opus 5.5 --- src/CTLD_crate.lua | 28 ++++ src/CTLD_fatCow.lua | 243 ++++++++++++++++++++++++++----- tests/ci/unit/fatcow_ai_spec.lua | 80 ++++++++-- 3 files changed, 305 insertions(+), 46 deletions(-) diff --git a/src/CTLD_crate.lua b/src/CTLD_crate.lua index a30bc7f0..5f6f1d55 100644 --- a/src/CTLD_crate.lua +++ b/src/CTLD_crate.lua @@ -2241,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 index bb3d66d9..85b8eff4 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -533,6 +533,7 @@ 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: the CH-47Fbl1's M_fuel_max in DCS's db.Units.lua. -- A scripted aircraft created without fuel falls out of the sky. @@ -541,6 +542,9 @@ local FOLLOW_OFFSET = { x = -200, y = 60, z = 150 } -- behind, above and to 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 @@ -653,14 +657,27 @@ local function flightLedBy(unit) 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 escorted flight's leader lands → the cow lands next to it; the cow lands → it deploys --- after fatCowDeployDelay, or despawns when it was going home. +--- 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) @@ -689,18 +706,19 @@ function CTLDFatCowManager:onLand(event) local flight = flightLedBy(unit) local escort = flight and self:aiCowOfFlight(flight) - if escort and escort.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING then + if escort and escort.ai.phase == CTLDFatCowManager.PHASE_FOLLOWING and not flightHasPlayer(flight) then self:_landNextTo(escort, unit) end end ---- The escorted flight's leader takes off → the cow packs and follows again, or goes home if empty. +--- 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 then return end + 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) @@ -711,13 +729,113 @@ function CTLDFatCowManager:_onAITakeoff(name) 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 ---- F10 "Request a Fat Cow": only on the ground inside a friendly logistic zone, one cow per flight. +--- 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", from anywhere: it takes off from the friendly logistic zone nearest to +-- the requester and joins the flight. One cow per flight. function CTLDFatCowManager:requestAICow(unitName) local t = Unit.getByName(unitName) if not (t and t:isExist()) then return end @@ -727,31 +845,29 @@ function CTLDFatCowManager:requestAICow(unitName) trigger.action.outTextForGroup(gid, ctld.tr("Your flight already has a Fat Cow."), 10) return end - if ctld.utils.inAir(t) then - trigger.action.outTextForGroup(gid, ctld.tr("Land in a logistic zone to request a Fat Cow."), 10) - return - end - local zone = CTLDZoneManager.getInstance():getLogisticZoneAtPoint(t:getPoint(), t:getCoalition()) + local p = t:getPoint() + local zone, distance = self:nearestHomeZone(p, t:getCoalition()) if not zone then - trigger.action.outTextForGroup(gid, ctld.tr("Land in a logistic zone to request a Fat Cow."), 10) + 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 p = t:getPoint() - local hdg = ctld.utils.getHeadingInRadians("CTLDFatCowManager", t, true) - local sx, sz = p.x - math.cos(hdg) * 80, p.z - math.sin(hdg) * 80 -- 80 m behind the requester + 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 = "TakeOffGroundHot", action = "From Ground Area Hot", - x = sx, y = sz, alt = land.getHeight({ x = sx, y = sz }), alt_type = "BARO", speed = 0, + 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 = sx, y = sz, heading = hdg, + 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 = {} }, } }, } @@ -761,13 +877,16 @@ function CTLDFatCowManager:requestAICow(unitName) trigger.action.outTextForGroup(gid, ctld.tr("The Fat Cow could not be brought in."), 10) return end - local home = { x = sx, y = p.y, z = sz } - local mgr = self + 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() mgr:addAICow(groupName, flight, home) end, nil, timer.getTime() + 1) + ctld.scheduler.schedule(function() + local cow = mgr:addAICow(groupName, flight, home) + if cow then cow.ai.homeName = zoneName end + end, nil, timer.getTime() + 1) trigger.action.outTextForGroup(gid, - ctld.tr("Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later.", - ctld.gs("fatCowDeployDelay")), 15) + 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 "Release the Fat Cow": it packs and goes home. @@ -787,23 +906,79 @@ 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 - menu:addCommand(path, ctld.tr("Request a Fat Cow"), - function(arg) mgr():requestAICow(arg.unitName) end, { unitName = playerObj.unitName }, { order = 1 }) - menu:addCommand(path, ctld.tr("Release the Fat Cow"), - function(arg) mgr():releaseAICow(arg.unitName) end, { unitName = playerObj.unitName }, { order = 2 }) - menu:addCommand(path, ctld.tr("Fat Cow stock"), - function(arg) mgr():reportFlightCowStock(arg.unitName) end, { unitName = playerObj.unitName }, { order = 3 }) + local u = { unitName = playerObj.unitName } + menu:addCommand(path, ctld.tr("Request a Fat Cow"), function(arg) mgr():requestAICow(arg.unitName) end, u, { order = 1 }) + 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("Release the Fat Cow"), function(arg) mgr():releaseAICow(arg.unitName) end, u, { order = 8 }) +end + +local PHASE_TEXT = { + holding = "holding", + following = "following your flight", + landing = "landing next to your leader", + landed = "on the ground", + returning = "going home", +} + +--- 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 phase = (cow.state == CTLDFatCow.STATE_DEPLOYED) and "FARP open" or PHASE_TEXT[cow.ai.phase] or cow.ai.phase + local lines = { ctld.tr("Fat Cow: %1, %2 km at %3°.", ctld.tr(phase), 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 -function CTLDFatCowManager:reportFlightCowStock(unitName) +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 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) + 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 - self:reportStock(cow.unitName, ctld.utils.getGroupId(t)) + 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 ────────────────────────────────────── diff --git a/tests/ci/unit/fatcow_ai_spec.lua b/tests/ci/unit/fatcow_ai_spec.lua index 677e9791..5381d71f 100644 --- a/tests/ci/unit/fatcow_ai_spec.lua +++ b/tests/ci/unit/fatcow_ai_spec.lua @@ -1,7 +1,8 @@ ---@diagnostic disable -- tests/ci/unit/fatcow_ai_spec.lua --- FEAT-FAT-COW ticket 04: an AI cow follows a flight, lands next to its leader, deploys after the --- delay, packs when the leader takes off, goes home when empty or released. +-- 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" @@ -31,6 +32,11 @@ describe("AI Fat Cow", function() 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 } @@ -69,7 +75,7 @@ describe("AI Fat Cow", function() 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.getLogisticZoneAtPoint, gs = ctld.gs, cargo = trigger.action.setUnitInternalCargo, + 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 @@ -84,7 +90,10 @@ describe("AI Fat Cow", function() 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.getLogisticZoneAtPoint = function() return inZone and {} or nil 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 } params.farpName = "FatCow_FARP-1" @@ -106,25 +115,28 @@ describe("AI Fat Cow", function() 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.getLogisticZoneAtPoint, ctld.gs = saved.zone, saved.gs + 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() + 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") runScheduled() local cow = mgr:aiCowOfFlight("Gunfighter") return leader, cow end - it("is requested on the ground in a logistic zone, spawned hot with fuel, and follows the flight", function() - local _, cow = flightWithCow() + 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("TakeOffGroundHot", added[1].route.points[1].type) + assert.equals("Turning Point", added[1].route.points[1].type) + assert.equals(5000, added[1].units[1].x) assert.is_true(added[1].units[1].payload.fuel > 0) assert.is_not_nil(cow) assert.equals(7200, cow.stock.fuelKg) @@ -134,12 +146,10 @@ describe("AI Fat Cow", function() assert.equals(7, t.params.groupId) end) - it("is refused in the air, outside a logistic zone, and a second time for the same flight", function() + 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 - mgr:requestAICow("Gunfighter-1") - leader.airborne = false inZone = false mgr:requestAICow("Gunfighter-1") assert.equals(0, #added) @@ -148,6 +158,52 @@ describe("AI Fat Cow", function() runScheduled() mgr:requestAICow("Gunfighter-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("AGM_114K 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() From 022538aea032dd33a5d4a5305709d27339716f96 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 16:54:28 +0100 Subject: [PATCH 26/36] feat(fat-cow): CH-47 loadouts straight into the hold, FARP status for the CH-47 too Load: Full fuel / 4 Hellfires + max fuel / Max weapons + max fuel (fatCowLoadouts), Unload the Fat Cow crates. Mission FARP warehouses are logged at start, a measurement before FARP status covers every FARP. Co-Authored-By: Claude Opus 5.5 --- src/CTLD_config.yaml | 19 ++++ src/CTLD_config_schema.yaml | 6 ++ src/CTLD_fatCow.lua | 118 ++++++++++++++++++++-- src/CTLD_i18n_en.lua | 43 +++++++- src/CTLD_i18n_es.lua | 43 +++++++- src/CTLD_i18n_fr.lua | 43 +++++++- src/CTLD_i18n_ko.lua | 43 +++++++- tests/ci/data/catalogue_shapes/2.3.0.json | 1 + tests/ci/data/config_defaults.json | 41 ++++++++ tests/ci/unit/fatcow_piloted_spec.lua | 68 +++++++++++++ 10 files changed, 404 insertions(+), 21 deletions(-) diff --git a/src/CTLD_config.yaml b/src/CTLD_config.yaml index ec96c51b..79aaab8f 100644 --- a/src/CTLD_config.yaml +++ b/src/CTLD_config.yaml @@ -296,6 +296,25 @@ mm_facing: count: 300 fatCowDeployDelay: 60 fatCowEnabled: true + fatCowLoadouts: + - desc: Full fuel + crates: + - crate: Fat Cow fuel tank (800 gal) + count: 3 + - desc: 4 Hellfires + max fuel + crates: + - crate: Fat Cow fuel tank (800 gal) + count: 3 + - crate: Fat Cow Hellfires (x4) + count: 1 + - desc: Max weapons + max fuel + crates: + - crate: Fat Cow fuel tank (800 gal) + count: 3 + - crate: Fat Cow Hellfires (x4) + count: 3 + - crate: Fat Cow 30 mm (x300) + count: 1 fatCowTypes: - CH-47Fbl1 fobCrateCollectionRadius: 750 diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index 538015d4..1919fa59 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -473,6 +473,12 @@ fatCowCrates: description: en: 'What a player cow can load: each crate has a desc, a weight (kg in the hold, and its catalogue key) and either fuelKg (jet fuel) or an item (a DCS warehouse item name) with its count. Default: an 800 gal ERFS tank of 2400 kg, 4 Hellfires, 300 rounds of 30 mm.' fr: "Ce qu'une cow pilotée peut charger : chaque caisse a un desc, un poids (kg en soute, et sa clé dans le catalogue) et soit fuelKg (carburant jet), soit un item (nom d'article d'entrepôt DCS) avec son nombre. Par défaut : un réservoir ERFS de 800 gal de 2400 kg, 4 Hellfire, 300 obus de 30 mm." +fatCowLoadouts: + group: fob + label: {en: Fat Cow loadouts, fr: Lots de chargement Fat Cow} + description: + en: 'What a player cow can load at once, straight into the hold, in a logistic zone: each loadout has a desc and a list of crates, each a fatCowCrates desc with its count. Default: full fuel; 4 Hellfires + max fuel; max weapons + max fuel (about 8 100 kg, what a CH-47F full of internal fuel can lift, in 7 of its 8 crate slots).' + fr: "Ce qu'une cow pilotée peut charger d'un coup, directement en soute, dans une zone logistique : chaque lot a un desc et une liste de caisses, chacune un desc de fatCowCrates avec son nombre. Par défaut : plein carburant ; 4 Hellfire + carburant max ; armement max + carburant max (environ 8 100 kg, ce qu'un CH-47F plein de carburant interne peut emporter, sur 7 de ses 8 places de caisse)." fatCowDeployDelay: group: fob label: {en: AI Fat Cow deployment delay, fr: Délai de déploiement de la cow IA} diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 85b8eff4..04782762 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -291,9 +291,30 @@ function CTLDFatCowManager:start() bridge:register(self, world.event.S_EVENT_CRASH, "onDead") bridge:register(self, world.event.S_EVENT_PLAYER_LEAVE_UNIT, "onPlayerLeave") self:_activateMissionCows() + 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() @@ -313,6 +334,7 @@ function CTLDFatCowManager:_injectCrateDescriptors() cratesRequired = 1, showSets = false, fatCow = content, + fatCowKey = def.desc, -- the untranslated name loadouts refer to }) self._crateDescs[#self._crateDescs + 1] = d end @@ -367,6 +389,72 @@ function CTLDFatCowManager:requestCrate(unitName, weight) end --- Deploys the FARP with the cow crates on board as its stock. The crates stay on board. +--- 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 + +--- F10 "Load: ": every crate of 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 byKey = {} + for _, d in ipairs(self._crateDescs or {}) do byKey[d.fatCowKey] = d end + local wanted = {} + for _, line in ipairs(loadout.crates or {}) do + local d = byKey[line.crate] + if not d then + ctld.utils.log("WARN", "CTLDFatCowManager: loadout '%s' names an unknown crate '%s'", tostring(loadout.desc), tostring(line.crate)) + trigger.action.outTextForGroup(gid, ctld.tr("This loadout cannot be loaded."), 10) + return + end + for _ = 1, (line.count or 1) do wanted[#wanted + 1] = d end + 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 _, d in ipairs(wanted) do loaded[#loaded + 1] = cm:loadIntoHold(d, t, unitName) end + 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 + function CTLDFatCowManager:deployFromHold(unitName) local t = Unit.getByName(unitName) if not (t and t:isExist()) then return end @@ -501,12 +589,21 @@ function CTLDFatCowManager:buildMenuSection(playerObj, menu) function(arg) mgr():requestCrate(arg.unitName, arg.weight) end, { unitName = playerObj.unitName, weight = d.weight }, { order = i }) end + for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do + menu:addCommand(path, ctld.tr("Load: %1", ctld.tr(l.desc or "?")), + function(arg) mgr():loadLoadout(arg.unitName, arg.index) end, + { unitName = playerObj.unitName, index = i }, { order = 10 + 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 @@ -917,13 +1014,17 @@ function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) menu:addCommand(path, ctld.tr("Release the Fat Cow"), function(arg) mgr():releaseAICow(arg.unitName) end, u, { order = 8 }) end -local PHASE_TEXT = { - holding = "holding", - following = "following your flight", - landing = "landing next to your leader", - landed = "on the ground", - returning = "going home", -} +-- 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) @@ -939,8 +1040,7 @@ function CTLDFatCowManager:reportFlightCowStatus(unitName) 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 phase = (cow.state == CTLDFatCow.STATE_DEPLOYED) and "FARP open" or PHASE_TEXT[cow.ai.phase] or cow.ai.phase - local lines = { ctld.tr("Fat Cow: %1, %2 km at %3°.", ctld.tr(phase), string.format("%.1f", d / 1000), bearing) } + 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 diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index 0a65e928..fa6a00ac 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.29" +ctld.i18n["en"].translation_version = "1.31" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -625,10 +625,10 @@ 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 Hellfires (x4)"] = "Fat Cow Hellfires (x4)" ctld.i18n["en"]["Fat Cow released: it goes home."] = "Fat Cow released: it goes home." -ctld.i18n["en"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later." +-- STALE: ctld.i18n["en"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later." ctld.i18n["en"]["Fat Cow stock"] = "Fat Cow stock" ctld.i18n["en"]["Fat Cow stock: %1"] = "Fat Cow stock: %1" -ctld.i18n["en"]["Land in a logistic zone to request a Fat Cow."] = "Land in a logistic zone to request a Fat Cow." +-- STALE: ctld.i18n["en"]["Land in a logistic zone to request a Fat Cow."] = "Land in a logistic zone to request a Fat Cow." 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." @@ -642,3 +642,40 @@ ctld.i18n["en"]["The Fat Cow FARP could not be deployed."] = "The Fat Cow FARP c 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"]["4 Hellfires + max fuel"] = "4 Hellfires + max fuel" +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"]["Full fuel"] = "Full fuel" +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"]["Load: %1"] = "Load: %1" +ctld.i18n["en"]["Loaded into your hold: %1"] = "Loaded into your hold: %1" +ctld.i18n["en"]["Max weapons + max fuel"] = "Max weapons + max fuel" +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" diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 0baaa8f6..19e15676 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.29" +ctld.i18n["es"].translation_version = "1.31" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -632,10 +632,10 @@ ctld.i18n["es"]["Fat Cow FARP packed: %1 left."] = "FARP Fat Cow recogido: queda ctld.i18n["es"]["Fat Cow fuel tank (800 gal)"] = "Tanque Fat Cow (800 gal)" ctld.i18n["es"]["Fat Cow Hellfires (x4)"] = "Hellfire Fat Cow (x4)" ctld.i18n["es"]["Fat Cow released: it goes home."] = "Fat Cow liberado: regresa a la base." -ctld.i18n["es"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow solicitado: sigue a tu escuadrilla, aterriza junto a tu líder y abre su FARP %1 s después." +-- STALE: ctld.i18n["es"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow solicitado: sigue a tu escuadrilla, aterriza junto a tu líder y abre su FARP %1 s después." 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 in a logistic zone to request a Fat Cow."] = "Aterriza en una zona logística para solicitar un Fat Cow." +-- STALE: ctld.i18n["es"]["Land in a logistic zone to request a Fat Cow."] = "Aterriza en una zona logística para solicitar un Fat Cow." 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." @@ -649,3 +649,40 @@ ctld.i18n["es"]["The Fat Cow FARP could not be deployed."] = "No se pudo despleg 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"]["4 Hellfires + max fuel"] = "4 Hellfire + combustible máx." +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"]["Full fuel"] = "Combustible completo" +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"]["Load: %1"] = "Cargar: %1" +ctld.i18n["es"]["Loaded into your hold: %1"] = "Cargado en la bodega: %1" +ctld.i18n["es"]["Max weapons + max fuel"] = "Armamento máx. + combustible máx." +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" diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index 00ce0505..f1e7a706 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.29" +ctld.i18n["fr"].translation_version = "1.31" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -657,10 +657,10 @@ ctld.i18n["fr"]["Fat Cow FARP packed: %1 left."] = "FARP Fat Cow repliée : il r ctld.i18n["fr"]["Fat Cow fuel tank (800 gal)"] = "Réservoir Fat Cow (800 gal)" ctld.i18n["fr"]["Fat Cow Hellfires (x4)"] = "Hellfire Fat Cow (x4)" ctld.i18n["fr"]["Fat Cow released: it goes home."] = "Fat Cow libérée : elle rentre." -ctld.i18n["fr"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow demandée : elle suit votre patrouille, se pose à côté de votre leader et ouvre sa FARP %1 s plus tard." +-- STALE: ctld.i18n["fr"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow demandée : elle suit votre patrouille, se pose à côté de votre leader et ouvre sa FARP %1 s plus tard." 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 in a logistic zone to request a Fat Cow."] = "Posez-vous dans une zone logistique pour demander une Fat Cow." +-- STALE: ctld.i18n["fr"]["Land in a logistic zone to request a Fat Cow."] = "Posez-vous dans une zone logistique pour demander une Fat Cow." 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." @@ -674,3 +674,40 @@ ctld.i18n["fr"]["The Fat Cow FARP could not be deployed."] = "La FARP Fat Cow n' 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"]["4 Hellfires + max fuel"] = "4 Hellfire + carburant max" +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"]["Full fuel"] = "Plein carburant" +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"]["Load: %1"] = "Charger : %1" +ctld.i18n["fr"]["Loaded into your hold: %1"] = "Chargé en soute : %1" +ctld.i18n["fr"]["Max weapons + max fuel"] = "Armement max + carburant max" +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" diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 562ff709..245c2569 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.29" +ctld.i18n["ko"].translation_version = "1.31" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -514,10 +514,10 @@ 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 Hellfires (x4)"] = "Fat Cow 헬파이어 (x4)" ctld.i18n["ko"]["Fat Cow released: it goes home."] = "Fat Cow 해제: 귀환합니다." -ctld.i18n["ko"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow 요청됨: 편대를 따라가 편대장 옆에 착륙하고 %1초 후 FARP를 엽니다." +-- STALE: ctld.i18n["ko"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow 요청됨: 편대를 따라가 편대장 옆에 착륙하고 %1초 후 FARP를 엽니다." ctld.i18n["ko"]["Fat Cow stock"] = "Fat Cow 재고" ctld.i18n["ko"]["Fat Cow stock: %1"] = "Fat Cow 재고: %1" -ctld.i18n["ko"]["Land in a logistic zone to request a Fat Cow."] = "Fat Cow를 요청하려면 보급 구역에 착륙하세요." +-- STALE: ctld.i18n["ko"]["Land in a logistic zone to request a Fat Cow."] = "Fat Cow를 요청하려면 보급 구역에 착륙하세요." 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 없음." @@ -531,3 +531,40 @@ 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"]["4 Hellfires + max fuel"] = "헬파이어 4발 + 최대 연료" +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"]["Full fuel"] = "연료 만재" +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"]["Load: %1"] = "적재: %1" +ctld.i18n["ko"]["Loaded into your hold: %1"] = "화물칸 적재 완료: %1" +ctld.i18n["ko"]["Max weapons + max fuel"] = "최대 무장 + 최대 연료" +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"] = "지상" diff --git a/tests/ci/data/catalogue_shapes/2.3.0.json b/tests/ci/data/catalogue_shapes/2.3.0.json index 294820aa..b0dc0254 100644 --- a/tests/ci/data/catalogue_shapes/2.3.0.json +++ b/tests/ci/data/catalogue_shapes/2.3.0.json @@ -88,6 +88,7 @@ "fatCowCrates", "fatCowDeployDelay", "fatCowEnabled", + "fatCowLoadouts", "fatCowTypes", "fieldExtractTroopWeight", "fobCrateCollectionRadius", diff --git a/tests/ci/data/config_defaults.json b/tests/ci/data/config_defaults.json index f79daee5..aaa3309f 100644 --- a/tests/ci/data/config_defaults.json +++ b/tests/ci/data/config_defaults.json @@ -380,6 +380,47 @@ ], "fatCowDeployDelay": 60, "fatCowEnabled": true, + "fatCowLoadouts": [ + { + "crates": [ + { + "count": 3, + "crate": "Fat Cow fuel tank (800 gal)" + } + ], + "desc": "Full fuel" + }, + { + "crates": [ + { + "count": 3, + "crate": "Fat Cow fuel tank (800 gal)" + }, + { + "count": 1, + "crate": "Fat Cow Hellfires (x4)" + } + ], + "desc": "4 Hellfires + max fuel" + }, + { + "crates": [ + { + "count": 3, + "crate": "Fat Cow fuel tank (800 gal)" + }, + { + "count": 3, + "crate": "Fat Cow Hellfires (x4)" + }, + { + "count": 1, + "crate": "Fat Cow 30 mm (x300)" + } + ], + "desc": "Max weapons + max fuel" + } + ], "fatCowTypes": [ "CH-47Fbl1" ], diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua index b33f61b9..01a9b1a4 100644 --- a/tests/ci/unit/fatcow_piloted_spec.lua +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -194,6 +194,74 @@ describe("Piloted cow commands", function() 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) + + local function indexOf(desc) + for i, l in ipairs(ctld.gs("fatCowLoadouts")) do if l.desc == desc then return i end end + end + + it("puts a whole loadout straight into the hold", function() + mgr:loadLoadout("Cow-1", indexOf("Max weapons + max fuel")) + local onboard = cm:cratesOnboard("Cow-1") + assert.equals(7, #onboard) + local stock = CTLDFatCow.stockOfCrates(onboard) + assert.equals(7200, stock.fuelKg) + assert.equals(12, stock.items[HELLFIRE]) + assert.equals(300, stock.items["weapons.shells.M230_HEDP M789"]) + for _, c in ipairs(onboard) do assert.is_nil(c.dcsStatic) end -- nothing left on the ground + end) + + it("refuses a loadout the hold has no room for, and outside a logistic zone", function() + mgr:loadLoadout("Cow-1", indexOf("Max weapons + max fuel")) + mgr:loadLoadout("Cow-1", indexOf("Full fuel")) -- 7 + 3 > 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", indexOf("Full fuel")) + 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 From 5cd3286ad8d2e07e21bcad146f059d079e21a4f3 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 16:57:13 +0100 Subject: [PATCH 27/36] docs(fat-cow): loadouts, F10-commanded AI cow, Fat Cow and FARP status Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++---- docs/mission-maker/fat-cow.fr.md | 26 +++++++++++++++-- docs/mission-maker/fat-cow.md | 26 +++++++++++++++-- docs/pilot/fat-cow.fr.md | 50 ++++++++++++++++++++------------ docs/pilot/fat-cow.md | 50 ++++++++++++++++++++------------ 5 files changed, 117 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72741769..e5c94507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,18 @@ Versioning follows [Semantic Versioning](https://semver.org/). 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** requests Fat Cow crates in a logistic zone (an 800 gal ERFS tank of 2 400 kg of jet fuel, 4 Hellfires, 300 rounds of 30 mm), loads them into its hold, lands, and **Deploy the Fat Cow FARP** from F10 → CTLD → Fat Cow. - 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. -- Any other player can **Request a Fat Cow** while on the ground in a friendly logistic zone: an AI CH-47 follows the flight, lands next to its leader, opens its FARP 60 s later (`fatCowDeployDelay`), packs when the leader takes off, and flies home to despawn once empty or on **Release the Fat Cow**. +- A **player-flown CH-47F** loads Fat Cow crates in a logistic zone, at once (**Load: Full fuel**, **4 Hellfires + max fuel**, **Max weapons + max fuel**, `fatCowLoadouts`, straight into the hold) or one at a time (an 800 gal ERFS tank of 2 400 kg of jet fuel, 4 Hellfires, 300 rounds of 30 mm), lands, and **Deploy the Fat Cow FARP** from F10 → CTLD → Fat Cow. + 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: 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**, **Release the Fat Cow** (home, despawn). **Fat Cow status** gives its phase, distance, bearing, joining time and stock. By default it carries 7 200 kg of fuel and 6 Hellfires (`fatCowAIFuelKg`, `fatCowAIItems`). -- A cow placed in the mission (a late-activated helicopter group) escorts a named flight when declared in `fatCowAICows` (`{ group, escort }`): the contract the VEAF Mission Creation Tools rely on. +- **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 }`): 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. 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). 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 whenever a player joins, with what the server holds: whether DCS shares a scripted warehouse with clients was not measured. -- New settings `fatCowEnabled`, `fatCowTypes`, `fatCowCrates`, `fatCowDeployDelay`, `fatCowAIType`, `fatCowAIFuelKg`, `fatCowAIItems`, `fatCowAICows`; catalogue version 2.3.0. +- New settings `fatCowEnabled`, `fatCowTypes`, `fatCowCrates`, `fatCowLoadouts`, `fatCowDeployDelay`, `fatCowAIType`, `fatCowAIFuelKg`, `fatCowAIItems`, `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. diff --git a/docs/mission-maker/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md index f7d0a93b..fc1d3ca5 100644 --- a/docs/mission-maker/fat-cow.fr.md +++ b/docs/mission-maker/fat-cow.fr.md @@ -30,6 +30,7 @@ Tous dans la famille `fob` de la configuration. | `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, 30 mm | Ce qu'une cow pilotée peut charger (voir plus bas). | +| `fatCowLoadouts` | plein carburant ; 4 Hellfire + carburant max ; armement max + carburant max | Ce qu'une cow pilotée peut charger d'un coup, directement en soute (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. | | `fatCowAIFuelKg` | `7200` | Carburant jet emporté par une cow IA (trois réservoirs ERFS de 800 gal). | @@ -57,9 +58,27 @@ L'entrée 30 mm (`weapons.shells.M230_HEDP M789`) est le nom que DCS donne aux o Les caisses cow n'apparaissent ni dans **Request Equipment** ni dans **Unpack Crate** : on les demande depuis le menu Fat Cow, et on les déploie depuis la soute. +### Lots de chargement + +Chaque entrée de `fatCowLoadouts` est une commande **Charger : ** : ses caisses, désignées par leur desc dans `fatCowCrates`, vont directement en soute, dans une zone logistique. + +```yaml +fatCowLoadouts: +- desc: 4 Hellfires + max fuel + crates: + - crate: Fat Cow fuel tank (800 gal) + count: 3 + - crate: Fat Cow Hellfires (x4) + count: 1 +``` + +Un lot qui dépasserait le `maxCratesOnboard` de l'appareil (8 pour le CH-47F) est refusé. +Le lot par défaut « Armement max + carburant max » pèse environ 8 100 kg : ce qu'un CH-47F plein de carburant interne peut emporter (22 680 kg au plus, 11 400 kg à vide, 3 054 kg de carburant, d'après la base d'unités de DCS). + ## Placer une cow IA dans une mission -Une patrouille peut demander une cow IA dans une zone logistique en cours de 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, Libérer) : 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). @@ -73,7 +92,10 @@ fatCowAICows: ``` 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. -La cow se comporte ensuite comme une cow demandée : elle suit, se pose à côté du leader, se déploie après `fatCowDeployDelay`, se replie quand le leader décolle, et rentre à son point de départ une fois vide. +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 }`. 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 index 87284482..0264c57a 100644 --- a/docs/mission-maker/fat-cow.md +++ b/docs/mission-maker/fat-cow.md @@ -30,6 +30,7 @@ All in the `fob` family of the configuration. | `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, 30 mm | What a player cow can load (see below). | +| `fatCowLoadouts` | full fuel; 4 Hellfires + max fuel; max weapons + max fuel | What a player cow can load at once, straight into the hold (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**. | | `fatCowAIFuelKg` | `7200` | Jet fuel an AI cow carries (three 800 gal ERFS tanks). | @@ -57,9 +58,27 @@ The 30 mm entry (`weapons.shells.M230_HEDP M789`) is the name DCS gives the Apac Cow crates never show in **Request Equipment** nor in **Unpack Crate**: they are requested from the Fat Cow menu, and deployed from the hold. +### Loadouts + +Each `fatCowLoadouts` entry is one **Load: ** command: its crates, named by their `fatCowCrates` desc, go straight into the hold, in a logistic zone. + +```yaml +fatCowLoadouts: +- desc: 4 Hellfires + max fuel + crates: + - crate: Fat Cow fuel tank (800 gal) + count: 3 + - crate: Fat Cow Hellfires (x4) + count: 1 +``` + +A loadout that would exceed the aircraft's `maxCratesOnboard` (8 for the CH-47F) is refused. +The default "Max weapons + max fuel" is about 8 100 kg: what a CH-47F full of internal fuel can lift (22 680 kg at most, 11 400 kg empty, 3 054 kg of fuel, from DCS's units database). + ## Placing an AI cow in a mission -A flight can request an AI cow in a logistic zone during the 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, Release): 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). @@ -73,7 +92,10 @@ fatCowAICows: ``` 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. -The cow then behaves as a requested one: it follows, lands next to the leader, deploys after `fatCowDeployDelay`, packs when the leader takes off, and flies back to where it started once empty. +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 }`. CTLD never recognises a cow by its name. diff --git a/docs/pilot/fat-cow.fr.md b/docs/pilot/fat-cow.fr.md index 90bcdf74..4c18651f 100644 --- a/docs/pilot/fat-cow.fr.md +++ b/docs/pilot/fat-cow.fr.md @@ -8,26 +8,26 @@ 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) -Tout se trouve dans **F10 → CTLD → Fat Cow**. - 1. Posez-vous dans une zone logistique. -2. Demandez ce que vous voulez emporter, une caisse à la fois : - - **Réservoir Fat Cow (800 gal)** : 2 400 kg de carburant jet, et 2 400 kg dans votre soute ; - - **Hellfire Fat Cow (x4)** ; - - **Fat Cow 30 mm (x300)**. -3. Chargez les caisses en soute, comme n'importe quelle caisse CTLD. -4. Volez jusqu'aux hélicoptères d'attaque qui ont besoin de vous, et posez-vous. -5. **Déployer la FARP Fat Cow.** +2. Chargez ce que vous voulez emporter : + - **Charger : Plein carburant** (3 réservoirs, 7 200 kg), **Charger : 4 Hellfire + carburant max** ou **Charger : Armement max + carburant max** (3 réservoirs, 12 Hellfire, 300 obus de 30 mm, environ 8 100 kg) : les caisses vont directement en soute ; + - ou une caisse à la fois, **Demander Réservoir Fat Cow (800 gal)** (2 400 kg de carburant jet, et 2 400 kg dans votre soute), **Demander Hellfire Fat Cow (x4)**, **Demander Fat Cow 30 mm (x300)**, puis chargez-les 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. -6. 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. -7. **Replier la FARP Fat Cow**, ou décollez simplement : la FARP est repliée pour vous. +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. !!! 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 ». @@ -35,17 +35,29 @@ Tout se trouve dans **F10 → CTLD → Fat Cow**. ## Demander une cow IA -Tout joueur dont l'appareil n'est pas une cow voit **F10 → CTLD → Fat Cow → Demander une Fat Cow**. +Tout joueur dont l'appareil n'est pas une cow peut **Demander une Fat Cow**, au sol comme en vol. +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 : -1. Posez-vous dans une zone logistique amie, et demandez-la là : une cow n'est jamais envoyée sur le terrain. -2. Un CH-47 démarre à 80 m derrière vous et suit votre patrouille. -3. Quand votre leader se pose, la cow se pose à côté de lui et ouvre sa FARP une minute plus tard. -4. Quand votre leader décolle, la cow replie sa FARP et vous suit à nouveau. -5. Une fois vide, ou quand vous utilisez **Libérer la Fat Cow**, elle rentre et disparaît. +| 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 | +| **Libérer la Fat Cow** | replie sa FARP et rentre, 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. Par défaut, une cow IA emporte 7 200 kg de carburant (trois réservoirs de 800 gal) et 6 Hellfire. Une cow par patrouille. !!! tip "Posez-vous à découvert" - La cow se pose à environ 120 m à gauche de votre leader. - Choisissez un terrain dégagé : un hélicoptère envoyé se poser en lisière de forêt y reste en stationnaire sans jamais toucher le sol. + 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 index d5eeb30f..3b1f8305 100644 --- a/docs/pilot/fat-cow.md +++ b/docs/pilot/fat-cow.md @@ -8,26 +8,26 @@ 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) -Everything is in **F10 → CTLD → Fat Cow**. - 1. Land in a logistic zone. -2. Request what you want to carry, one crate at a time: - - **Fat Cow fuel tank (800 gal)**: 2 400 kg of jet fuel, and 2 400 kg in your hold; - - **Fat Cow Hellfires (x4)**; - - **Fat Cow 30 mm (x300)**. -3. Load the crates into the hold, like any CTLD crate. -4. Fly to where the attack helicopters need you, and land. -5. **Deploy the Fat Cow FARP.** +2. Load what you want to carry: + - **Load: Full fuel** (3 tanks, 7 200 kg), **Load: 4 Hellfires + max fuel** or **Load: Max weapons + max fuel** (3 tanks, 12 Hellfires, 300 rounds of 30 mm, about 8 100 kg): the crates go straight into the hold; + - or one crate at a time, **Request Fat Cow fuel tank (800 gal)** (2 400 kg of jet fuel, and 2 400 kg in your hold), **Request Fat Cow Hellfires (x4)**, **Request Fat Cow 30 mm (x300)**, then load them 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. -6. The other helicopters land next to you and rearm and refuel through their ground crew menu, as on any FARP. -7. **Pack the Fat Cow FARP**, or simply take off: the FARP is packed for you. +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. !!! 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". @@ -35,17 +35,29 @@ Everything is in **F10 → CTLD → Fat Cow**. ## Asking for an AI cow -Any player whose aircraft is not a cow sees **F10 → CTLD → Fat Cow → Request a Fat Cow**. +Any player whose aircraft is not a cow can **Request a Fat Cow**, on the ground or in the air. +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: -1. Land in a friendly logistic zone, and request it there: a cow is never sent to you in the field. -2. A CH-47 starts up 80 m behind you and follows your flight. -3. When your leader lands, the cow lands next to your leader, and opens its FARP one minute later. -4. When your leader takes off, the cow packs and follows again. -5. Once it is empty, or when you use **Release the Fat Cow**, it flies home and disappears. +| 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 | +| **Release the Fat Cow** | packs its FARP and flies home, 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. By default an AI cow carries 7 200 kg of fuel (three 800 gal tanks) and 6 Hellfires. One cow per flight. !!! tip "Land in the open" - The cow lands about 120 m to the left of your leader. - Pick open ground: a helicopter sent to land at the edge of a forest hovers there and never touches down. + 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. From b119792eb6bc83214e4f81131b51bf22674a5da1 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 16:59:00 +0100 Subject: [PATCH 28/36] docs(fat-cow): first server run of the live check Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/tickets/06-live-check.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index 2c2d690b..c2db2a8c 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -31,6 +31,13 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo `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". + ## Procedure **Print Screen** where marked 📸. Nothing to copy: `dcs.log` and the screenshots are read afterwards. From 17271ffa0d007c57fd7aac7c0bc90d22e4f8c3c2 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 17:04:37 +0100 Subject: [PATCH 29/36] =?UTF-8?q?feat(fat-cow):=20Release=20the=20Fat=20Co?= =?UTF-8?q?w=20becomes=20RTB=20(Rentre=20=C3=A0=20la=20base)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/tickets/06-live-check.md | 2 +- CHANGELOG.md | 2 +- docs/mission-maker/fat-cow.fr.md | 2 +- docs/mission-maker/fat-cow.md | 2 +- docs/pilot/fat-cow.fr.md | 2 +- docs/pilot/fat-cow.md | 2 +- src/CTLD_fatCow.lua | 6 +++--- src/CTLD_i18n_en.lua | 10 +++++++--- src/CTLD_i18n_es.lua | 10 +++++++--- src/CTLD_i18n_fr.lua | 10 +++++++--- src/CTLD_i18n_ko.lua | 10 +++++++--- 11 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index c2db2a8c..c282be91 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -68,7 +68,7 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo 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** → **Release the Fat Cow**: it flies back to where it appeared and disappears. +6. `\` → **F10 Other** → **CTLD** → **Fat Cow** → **RTB**: it flies back to where it appeared and disappears. 7. Quit. ## Reading the results diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c94507..f73bdf77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ED announced a native Fat Cow on 2024-04-19, and again on 2024-08-23; DCS 2.9.30 - A **player-flown CH-47F** loads Fat Cow crates in a logistic zone, at once (**Load: Full fuel**, **4 Hellfires + max fuel**, **Max weapons + max fuel**, `fatCowLoadouts`, straight into the hold) or one at a time (an 800 gal ERFS tank of 2 400 kg of jet fuel, 4 Hellfires, 300 rounds of 30 mm), lands, and **Deploy the Fat Cow FARP** from F10 → CTLD → Fat Cow. 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: 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**, **Release the Fat Cow** (home, despawn). **Fat Cow status** gives its phase, distance, bearing, joining time and stock. + 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. By default it carries 7 200 kg of fuel and 6 Hellfires (`fatCowAIFuelKg`, `fatCowAIItems`). - **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 }`): 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. diff --git a/docs/mission-maker/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md index fc1d3ca5..53c68d48 100644 --- a/docs/mission-maker/fat-cow.fr.md +++ b/docs/mission-maker/fat-cow.fr.md @@ -78,7 +78,7 @@ Le lot par défaut « Armement max + carburant max » pèse environ 8 100 kg : c ## 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, Libérer) : la cow d'une patrouille de joueurs ne bouge jamais d'elle-même. +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). diff --git a/docs/mission-maker/fat-cow.md b/docs/mission-maker/fat-cow.md index 0264c57a..cda93874 100644 --- a/docs/mission-maker/fat-cow.md +++ b/docs/mission-maker/fat-cow.md @@ -78,7 +78,7 @@ The default "Max weapons + max fuel" is about 8 100 kg: what a CH-47F full of in ## 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, Release): a player flight's cow never moves on its own. +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). diff --git a/docs/pilot/fat-cow.fr.md b/docs/pilot/fat-cow.fr.md index 4c18651f..19061b38 100644 --- a/docs/pilot/fat-cow.fr.md +++ b/docs/pilot/fat-cow.fr.md @@ -46,7 +46,7 @@ La cow fait ensuite ce que votre patrouille lui dit, et rien d'elle-même : | **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 | -| **Libérer la Fat Cow** | replie sa FARP et rentre, où elle disparaît | +| **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. diff --git a/docs/pilot/fat-cow.md b/docs/pilot/fat-cow.md index 3b1f8305..9f99b7ca 100644 --- a/docs/pilot/fat-cow.md +++ b/docs/pilot/fat-cow.md @@ -46,7 +46,7 @@ The cow then does what your flight tells it, and nothing on its own: | **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 | -| **Release the Fat Cow** | packs its FARP and flies home, where it disappears | +| **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. diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 04782762..686d962b 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -986,7 +986,7 @@ function CTLDFatCowManager:requestAICow(unitName) zoneName, math.floor(distance / 1000 + 0.5), etaMinutes(distance)), 15) end ---- F10 "Release the Fat Cow": it packs and goes home. +--- 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 @@ -996,7 +996,7 @@ function CTLDFatCowManager:releaseAICow(unitName) return end self:_returnHome(cow) - trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow released: it goes home."), 10) + trigger.action.outTextForGroup(ctld.utils.getGroupId(t), ctld.tr("Fat Cow RTB."), 10) end function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) @@ -1011,7 +1011,7 @@ function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) 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("Release the Fat Cow"), function(arg) mgr():releaseAICow(arg.unitName) end, u, { order = 8 }) + 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. diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index fa6a00ac..3bbb25a2 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.31" +ctld.i18n["en"].translation_version = "1.32" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -624,7 +624,7 @@ 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 Hellfires (x4)"] = "Fat Cow Hellfires (x4)" -ctld.i18n["en"]["Fat Cow released: it goes home."] = "Fat Cow released: it goes home." +-- STALE: ctld.i18n["en"]["Fat Cow released: it goes home."] = "Fat Cow released: it goes home." -- STALE: ctld.i18n["en"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later." ctld.i18n["en"]["Fat Cow stock"] = "Fat Cow stock" ctld.i18n["en"]["Fat Cow stock: %1"] = "Fat Cow stock: %1" @@ -633,7 +633,7 @@ ctld.i18n["en"]["Land to deploy the Fat Cow FARP."] = "Land to deploy the Fat Co 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"]["Release the Fat Cow"] = "Release the Fat Cow" +-- STALE: ctld.i18n["en"]["Release the Fat Cow"] = "Release the Fat Cow" ctld.i18n["en"]["Request %1"] = "Request %1" 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." @@ -679,3 +679,7 @@ 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" diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 19e15676..97b02aaf 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.31" +ctld.i18n["es"].translation_version = "1.32" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -631,7 +631,7 @@ 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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" -ctld.i18n["es"]["Fat Cow released: it goes home."] = "Fat Cow liberado: regresa a la base." +-- STALE: ctld.i18n["es"]["Fat Cow released: it goes home."] = "Fat Cow liberado: regresa a la base." -- STALE: ctld.i18n["es"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow solicitado: sigue a tu escuadrilla, aterriza junto a tu líder y abre su FARP %1 s después." ctld.i18n["es"]["Fat Cow stock"] = "Existencias del Fat Cow" ctld.i18n["es"]["Fat Cow stock: %1"] = "Existencias del Fat Cow: %1" @@ -640,7 +640,7 @@ ctld.i18n["es"]["Land to deploy the Fat Cow FARP."] = "Aterriza para desplegar e 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"]["Release the Fat Cow"] = "Liberar el Fat Cow" +-- STALE: ctld.i18n["es"]["Release the Fat Cow"] = "Liberar el Fat Cow" ctld.i18n["es"]["Request %1"] = "Solicitar %1" 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." @@ -686,3 +686,7 @@ 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" diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index f1e7a706..96a25395 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.31" +ctld.i18n["fr"].translation_version = "1.32" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -656,7 +656,7 @@ 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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" -ctld.i18n["fr"]["Fat Cow released: it goes home."] = "Fat Cow libérée : elle rentre." +-- STALE: ctld.i18n["fr"]["Fat Cow released: it goes home."] = "Fat Cow libérée : elle rentre." -- STALE: ctld.i18n["fr"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow demandée : elle suit votre patrouille, se pose à côté de votre leader et ouvre sa FARP %1 s plus tard." ctld.i18n["fr"]["Fat Cow stock"] = "Stock de la Fat Cow" ctld.i18n["fr"]["Fat Cow stock: %1"] = "Stock de la Fat Cow : %1" @@ -665,7 +665,7 @@ ctld.i18n["fr"]["Land to deploy the Fat Cow FARP."] = "Posez-vous pour déployer 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"]["Release the Fat Cow"] = "Libérer la Fat Cow" +-- STALE: ctld.i18n["fr"]["Release the Fat Cow"] = "Libérer la Fat Cow" ctld.i18n["fr"]["Request %1"] = "Demander %1" 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." @@ -711,3 +711,7 @@ 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" diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 245c2569..1df4322a 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.31" +ctld.i18n["ko"].translation_version = "1.32" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -513,7 +513,7 @@ 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 Hellfires (x4)"] = "Fat Cow 헬파이어 (x4)" -ctld.i18n["ko"]["Fat Cow released: it goes home."] = "Fat Cow 해제: 귀환합니다." +-- STALE: ctld.i18n["ko"]["Fat Cow released: it goes home."] = "Fat Cow 해제: 귀환합니다." -- STALE: ctld.i18n["ko"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow 요청됨: 편대를 따라가 편대장 옆에 착륙하고 %1초 후 FARP를 엽니다." ctld.i18n["ko"]["Fat Cow stock"] = "Fat Cow 재고" ctld.i18n["ko"]["Fat Cow stock: %1"] = "Fat Cow 재고: %1" @@ -522,7 +522,7 @@ 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"]["Release the Fat Cow"] = "Fat Cow 해제" +-- STALE: ctld.i18n["ko"]["Release the Fat Cow"] = "Fat Cow 해제" ctld.i18n["ko"]["Request %1"] = "%1 요청" ctld.i18n["ko"]["Request a Fat Cow"] = "Fat Cow 요청" ctld.i18n["ko"]["The Fat Cow could not be brought in."] = "Fat Cow를 불러올 수 없습니다." @@ -568,3 +568,7 @@ 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"] = "기지로 귀환" From 346a9c3bdeb8bb2337a48d392356b770be1ee406 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 17:04:45 +0100 Subject: [PATCH 30/36] chore(fat-cow): drop the stale keys this branch created itself Co-Authored-By: Claude Opus 5.5 --- src/CTLD_i18n_en.lua | 4 ---- src/CTLD_i18n_es.lua | 4 ---- src/CTLD_i18n_fr.lua | 4 ---- src/CTLD_i18n_ko.lua | 4 ---- 4 files changed, 16 deletions(-) diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index 3bbb25a2..5e506144 100644 --- a/src/CTLD_i18n_en.lua +++ b/src/CTLD_i18n_en.lua @@ -624,16 +624,12 @@ 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 Hellfires (x4)"] = "Fat Cow Hellfires (x4)" --- STALE: ctld.i18n["en"]["Fat Cow released: it goes home."] = "Fat Cow released: it goes home." --- STALE: ctld.i18n["en"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later." ctld.i18n["en"]["Fat Cow stock"] = "Fat Cow stock" ctld.i18n["en"]["Fat Cow stock: %1"] = "Fat Cow stock: %1" --- STALE: ctld.i18n["en"]["Land in a logistic zone to request a Fat Cow."] = "Land in a logistic zone to request a Fat Cow." 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" --- STALE: ctld.i18n["en"]["Release the Fat Cow"] = "Release the Fat Cow" ctld.i18n["en"]["Request %1"] = "Request %1" 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." diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 97b02aaf..9f738ea5 100644 --- a/src/CTLD_i18n_es.lua +++ b/src/CTLD_i18n_es.lua @@ -631,16 +631,12 @@ 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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" --- STALE: ctld.i18n["es"]["Fat Cow released: it goes home."] = "Fat Cow liberado: regresa a la base." --- STALE: ctld.i18n["es"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow solicitado: sigue a tu escuadrilla, aterriza junto a tu líder y abre su FARP %1 s después." ctld.i18n["es"]["Fat Cow stock"] = "Existencias del Fat Cow" ctld.i18n["es"]["Fat Cow stock: %1"] = "Existencias del Fat Cow: %1" --- STALE: ctld.i18n["es"]["Land in a logistic zone to request a Fat Cow."] = "Aterriza en una zona logística para solicitar un Fat Cow." 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" --- STALE: ctld.i18n["es"]["Release the Fat Cow"] = "Liberar el Fat Cow" ctld.i18n["es"]["Request %1"] = "Solicitar %1" 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." diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index 96a25395..abebddfe 100644 --- a/src/CTLD_i18n_fr.lua +++ b/src/CTLD_i18n_fr.lua @@ -656,16 +656,12 @@ 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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" --- STALE: ctld.i18n["fr"]["Fat Cow released: it goes home."] = "Fat Cow libérée : elle rentre." --- STALE: ctld.i18n["fr"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow demandée : elle suit votre patrouille, se pose à côté de votre leader et ouvre sa FARP %1 s plus tard." ctld.i18n["fr"]["Fat Cow stock"] = "Stock de la Fat Cow" ctld.i18n["fr"]["Fat Cow stock: %1"] = "Stock de la Fat Cow : %1" --- STALE: ctld.i18n["fr"]["Land in a logistic zone to request a Fat Cow."] = "Posez-vous dans une zone logistique pour demander une Fat Cow." 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" --- STALE: ctld.i18n["fr"]["Release the Fat Cow"] = "Libérer la Fat Cow" ctld.i18n["fr"]["Request %1"] = "Demander %1" 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." diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 1df4322a..281f1994 100644 --- a/src/CTLD_i18n_ko.lua +++ b/src/CTLD_i18n_ko.lua @@ -513,16 +513,12 @@ 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 Hellfires (x4)"] = "Fat Cow 헬파이어 (x4)" --- STALE: ctld.i18n["ko"]["Fat Cow released: it goes home."] = "Fat Cow 해제: 귀환합니다." --- STALE: ctld.i18n["ko"]["Fat Cow requested: it follows your flight, lands next to your leader and opens its FARP %1 s later."] = "Fat Cow 요청됨: 편대를 따라가 편대장 옆에 착륙하고 %1초 후 FARP를 엽니다." ctld.i18n["ko"]["Fat Cow stock"] = "Fat Cow 재고" ctld.i18n["ko"]["Fat Cow stock: %1"] = "Fat Cow 재고: %1" --- STALE: ctld.i18n["ko"]["Land in a logistic zone to request a Fat Cow."] = "Fat Cow를 요청하려면 보급 구역에 착륙하세요." 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 철수" --- STALE: ctld.i18n["ko"]["Release the Fat Cow"] = "Fat Cow 해제" ctld.i18n["ko"]["Request %1"] = "%1 요청" ctld.i18n["ko"]["Request a Fat Cow"] = "Fat Cow 요청" ctld.i18n["ko"]["The Fat Cow could not be brought in."] = "Fat Cow를 불러올 수 없습니다." From 7433a633b6451d1c8fafb4f1556411b3f87994a4 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 17:53:09 +0100 Subject: [PATCH 31/36] docs(fat-cow): backlog index and PRD point to PR #273 Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 2 +- .backlog/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index 79e69e6e..52b675a9 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -1,6 +1,6 @@ # 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"); ticket 06, the live check, waits for Zip. +**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. diff --git a/.backlog/README.md b/.backlog/README.md index 18cde8b3..1bca3044 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +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) | in-progress — 01 spike ✅ (a runtime Invisible FARP serves, enforces a finite stock, and needs only a FARP Fuel Depot as supplier), 02-05 ✅ implemented, 06 waiting-human (live check, Zip) | 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` | +| [`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. | From 485c5ee2d3e412bdff2f6be6a1a0e0c2e09aa227 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 18:28:41 +0100 Subject: [PATCH 32/36] docs(fat-cow): deployFromHold's comment back above it Co-Authored-By: Claude Opus 5.5 --- src/CTLD_fatCow.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 686d962b..80871507 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -388,7 +388,6 @@ function CTLDFatCowManager:requestCrate(unitName, weight) trigger.action.outTextForGroup(gid, ctld.tr("%1 is next to you: load it into your hold.", desc.desc), 10) end ---- Deploys the FARP with the cow crates on board as its stock. The crates stay on board. --- 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 @@ -455,6 +454,7 @@ function CTLDFatCowManager:unloadHold(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 From b8f860f3fed3aec3e8e8f00903c8757624c4a91b Mon Sep 17 00:00:00 2001 From: David Pierron Date: Mon, 26 Oct 2026 22:20:33 +0100 Subject: [PATCH 33/36] docs(fat-cow): evening run findings and the fixes pending Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/tickets/06-live-check.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index c282be91..deceeb00 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -38,6 +38,16 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo - 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, fixes pending Zip's go + +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). Proposed: a Hellfire L crate, in the loadouts and the AI default; the warehouse item name `weapons.missiles.AGM_114L` is to be checked in game. + ## Procedure **Print Screen** where marked 📸. Nothing to copy: `dcs.log` and the screenshots are read afterwards. From 2020cd2b3e774fad7f893194a37bf01bd6b2f91d Mon Sep 17 00:00:00 2001 From: David Pierron Date: Tue, 27 Oct 2026 09:32:34 +0100 Subject: [PATCH 34/36] fix(fat-cow): one FARP per cow, stock guard, loadout profiles Evening server run findings and Zip's loadout profiles (lot 1): - one Invisible FARP per cow (CTLD_FatCow_FARP_), emptied on pack and added again at the next deployment, never destroyed (ghost FARPs, AI cow blowing up on take-off); an AI cow's FARP is reused by the next one - stock guard: counts that went up are put back, foreign weapons removed, every cow FARP re-synced every 10 s and on each birth (now and +2 s), FARPs of packed cows emptied - loadouts list fuel kg and items and are split into crates by content, not by translated crate name (refused on the French server) - four Hellfire + 30 mm profiles with two 800 gal tanks; crates of 20 laser, 20 radar (AGM-114L), 1200 rounds - AI cow: 80 % internal fuel, the requesting flight picks the loadout, mission cows take entry.loadout; fatCowAIFuelKg/fatCowAIItems removed - F10 menus regrouped into submenus; docs, CHANGELOG, ticket 06, roadmap Co-Authored-By: Claude Opus 5.5 --- .backlog/FEAT-FAT-COW/PRD.md | 4 +- .../FEAT-FAT-COW/tickets/06-live-check.md | 28 +- CHANGELOG.md | 15 +- dev/roadmap.md | 12 + docs/mission-maker/fat-cow.fr.md | 79 +++-- docs/mission-maker/fat-cow.md | 79 +++-- docs/pilot/fat-cow.fr.md | 10 +- docs/pilot/fat-cow.md | 10 +- src/CTLD_config.yaml | 70 ++-- src/CTLD_config_schema.yaml | 25 +- src/CTLD_fatCow.lua | 309 ++++++++++++++---- src/CTLD_i18n_en.lua | 23 +- src/CTLD_i18n_es.lua | 23 +- src/CTLD_i18n_fr.lua | 23 +- src/CTLD_i18n_ko.lua | 23 +- src/scenes/CTLD_fatCowScene.lua | 23 +- tests/ci/data/catalogue_shapes/2.3.0.json | 2 - tests/ci/data/config_defaults.json | 92 ++++-- tests/ci/unit/fatcow_ai_spec.lua | 69 +++- tests/ci/unit/fatcow_core_spec.lua | 137 +++++++- tests/ci/unit/fatcow_piloted_spec.lua | 60 +++- 21 files changed, 816 insertions(+), 300 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/PRD.md b/.backlog/FEAT-FAT-COW/PRD.md index 52b675a9..fa591a3f 100644 --- a/.backlog/FEAT-FAT-COW/PRD.md +++ b/.backlog/FEAT-FAT-COW/PRD.md @@ -42,8 +42,8 @@ ED announced a native Fat Cow on 2024-04-19 and again on 2024-08-23; it is absen 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: "" }]`. 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`), `fatCowDeployDelay` (60 s), `fatCowAIType` (`CH-47Fbl1`; empty disables the request), `fatCowAIFuelKg` (7 200), `fatCowAIItems` (6 × `weapons.missiles.AGM_114K`), `fatCowAICows`. Catalogue version 2.3.0. +- **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 diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index deceeb00..b9e25403 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -38,7 +38,7 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo - 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, fixes pending Zip's go +### 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`). @@ -46,7 +46,15 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo 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). Proposed: a Hellfire L crate, in the loadouts and the AI default; the warehouse item name `weapons.missiles.AGM_114L` is to be checked in game. +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 @@ -56,11 +64,11 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo 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 Fat Cow fuel tank (800 gal)**. 📸 - Then **Request Fat Cow Hellfires (x4)**. +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, AGM_114K x4. 📸 +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). @@ -74,13 +82,21 @@ The `Hawk` run needs nobody: CTLD logs it (`CTLDFatCowManager:` lines in `dcs.lo ### 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 CH-47 appears 80 m behind you. +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. + ## Reading the results | What is seen | Conclusion | diff --git a/CHANGELOG.md b/CHANGELOG.md index f73bdf77..51895a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,21 @@ Versioning follows [Semantic Versioning](https://semver.org/). 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 Fat Cow crates in a logistic zone, at once (**Load: Full fuel**, **4 Hellfires + max fuel**, **Max weapons + max fuel**, `fatCowLoadouts`, straight into the hold) or one at a time (an 800 gal ERFS tank of 2 400 kg of jet fuel, 4 Hellfires, 300 rounds of 30 mm), lands, and **Deploy the Fat Cow FARP** from F10 → CTLD → Fat Cow. +- 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 four default loadouts carry two tanks (4 800 kg) with 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: 7 800 to 8 200 kg, what a CH-47F lifts with 80 % of its internal fuel. 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: 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. +- 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. - By default it carries 7 200 kg of fuel and 6 Hellfires (`fatCowAIFuelKg`, `fatCowAIItems`). + 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 }`): 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. +- 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. 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 whenever a player joins, with what the server holds: whether DCS shares a scripted warehouse with clients was not measured. -- New settings `fatCowEnabled`, `fatCowTypes`, `fatCowCrates`, `fatCowLoadouts`, `fatCowDeployDelay`, `fatCowAIType`, `fatCowAIFuelKg`, `fatCowAIItems`, `fatCowAICows`; catalogue version 2.3.0. +- 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. diff --git a/dev/roadmap.md b/dev/roadmap.md index d4b30cd2..cddb0517 100644 --- a/dev/roadmap.md +++ b/dev/roadmap.md @@ -285,3 +285,15 @@ 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 — profils pour les hélicos russes (lot 2) + +Décidé par Zip le 2026-10-27, après FEAT-FAT-COW : des lots de chargement pour Ka-50 (Vikhr `Vikhr_M`, 45 kg) et Mi-24P (Ataka `Ataka_9M120`, 49,5 kg ; Shturm `AT_6`, 35,4 kg ; 30 mm 2A42 / GSh-30-2K), portés par une cow IA **Mi-26** (IA seulement dans DCS ; 56 000 kg max, 28 890 kg à vide, 9 600 kg de carburant, soit 19 430 kg de charge utile à 80 % de carburant). +Le réel : le Mi-26TZ est la vraie cow russe. +À cadrer : le choix du type de cow et des lots selon l'appareil du demandeur (un pilote de Ka-50 ne voit pas les lots Apache), et la mesure en jeu des noms d'entrepôt (`weapons.missiles.Vikhr_M`, `Ataka_9M120`, `AT_6`, obus `2A42_30_HE`…), aucun n'étant vérifié. + +## 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/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md index 53c68d48..a00ee48c 100644 --- a/docs/mission-maker/fat-cow.fr.md +++ b/docs/mission-maker/fat-cow.fr.md @@ -17,9 +17,19 @@ Quand une cow se déploie, CTLD joue la scène **Fat Cow FARP** à la cow : 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 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. +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 @@ -29,51 +39,57 @@ Tous dans la famille `fob` de la configuration. |---|---|---| | `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, 30 mm | Ce qu'une cow pilotée peut charger (voir plus bas). | -| `fatCowLoadouts` | plein carburant ; 4 Hellfire + carburant max ; armement max + carburant max | Ce qu'une cow pilotée peut charger d'un coup, directement en soute (voir plus bas). | +| `fatCowCrates` | réservoir, 20 Hellfire laser, 20 Hellfire radar, 1 200 obus de 30 mm | Les caisses dans lesquelles une cow pilotée emporte son stock (voir plus bas). | +| `fatCowLoadouts` | quatre lots Hellfire et 30 mm, 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. | -| `fatCowAIFuelKg` | `7200` | Carburant jet emporté par une cow IA (trois réservoirs ERFS de 800 gal). | -| `fatCowAIItems` | 6 × `weapons.missiles.AGM_114K` | Munitions emportées par une cow IA. | | `fatCowAICows` | `[]` | Cows placées dans la mission (voir plus bas). | -### Caisses cow +### Lots de chargement -Chaque entrée de `fatCowCrates` est une caisse qu'une cow pilotée peut demander : +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 -fatCowCrates: -- desc: Fat Cow fuel tank (800 gal) - weight: 2400 # kg en soute ; aussi la clé de la caisse dans le catalogue - fuelKg: 2400 # carburant jet contenu -- desc: Fat Cow Hellfires (x4) - weight: 250 - item: weapons.missiles.AGM_114K # nom d'article d'entrepôt DCS - count: 4 +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 entrée a soit `fuelKg`, soit `item` et `count`. -`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. -L'entrée 30 mm (`weapons.shells.M230_HEDP M789`) est le nom que DCS donne aux obus de l'Apache, pas encore vérifié comme article d'entrepôt. +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**. +Les quatre lots par défaut emportent tous deux réservoirs de 800 gal (4 800 kg), avec 40 Hellfire (25 laser, 15 radar) et 2 400 obus, 60 Hellfire (38 laser, 22 radar) et 1 200 obus, 40 Hellfire laser et 2 400 obus, ou 60 Hellfire laser et 1 200 obus. +Ils pèsent de 7 800 à 8 200 kg, 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 caisses cow n'apparaissent ni dans **Request Equipment** ni dans **Unpack Crate** : on les demande depuis le menu Fat Cow, et on les déploie depuis la soute. +`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. +`weapons.missiles.AGM_114` (l'AGM-114L, d'après le data mine DCS) et `weapons.shells.M230_HEDP M789` (les obus de l'Apache) ne sont pas encore vérifiés comme articles d'entrepôt. -### Lots de chargement +### Caisses cow -Chaque entrée de `fatCowLoadouts` est une commande **Charger : ** : ses caisses, désignées par leur desc dans `fatCowCrates`, vont directement en soute, dans une zone logistique. +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 -fatCowLoadouts: -- desc: 4 Hellfires + max fuel - crates: - - crate: Fat Cow fuel tank (800 gal) - count: 3 - - crate: Fat Cow Hellfires (x4) - count: 1 +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 ``` -Un lot qui dépasserait le `maxCratesOnboard` de l'appareil (8 pour le CH-47F) est refusé. -Le lot par défaut « Armement max + carburant max » pèse environ 8 100 kg : ce qu'un CH-47F plein de carburant interne peut emporter (22 680 kg au plus, 11 400 kg à vide, 3 054 kg de carburant, d'après la base d'unités de DCS). +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) et 1 200 obus de 30 mm (600 kg, 0,5 kg par obus emballage compris : une estimation). +Chaque lot par défaut tient en 6 ou 7 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 @@ -89,6 +105,7 @@ Pour donner sa cow à une patrouille dès le départ, placez la cow dans l'édit 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. @@ -97,5 +114,5 @@ Si c'est une patrouille de clients, ses joueurs commandent la cow depuis le F10, 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 }`. +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 index cda93874..ec4bf2a2 100644 --- a/docs/mission-maker/fat-cow.md +++ b/docs/mission-maker/fat-cow.md @@ -17,9 +17,19 @@ When a cow deploys, CTLD plays the **Fat Cow FARP** scene at the cow: 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, with the quantities the server holds: whether DCS shares a scripted warehouse with clients was not measured. +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 @@ -29,51 +39,57 @@ All in the `fob` family of the configuration. |---|---|---| | `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, 30 mm | What a player cow can load (see below). | -| `fatCowLoadouts` | full fuel; 4 Hellfires + max fuel; max weapons + max fuel | What a player cow can load at once, straight into the hold (see below). | +| `fatCowCrates` | fuel tank, 20 laser Hellfires, 20 radar Hellfires, 1 200 rounds of 30 mm | The crates a player cow carries its stock in (see below). | +| `fatCowLoadouts` | four Hellfire and 30 mm 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**. | -| `fatCowAIFuelKg` | `7200` | Jet fuel an AI cow carries (three 800 gal ERFS tanks). | -| `fatCowAIItems` | 6 × `weapons.missiles.AGM_114K` | Ammunition an AI cow carries. | | `fatCowAICows` | `[]` | Cows placed in the mission (see below). | -### Cow crates +### Loadouts -Each `fatCowCrates` entry is one crate a player cow can request: +Each `fatCowLoadouts` entry is what a cow carries: jet fuel in kg, and DCS warehouse items with their counts. ```yaml -fatCowCrates: -- desc: Fat Cow fuel tank (800 gal) - weight: 2400 # kg in the hold; also the crate's catalogue key - fuelKg: 2400 # jet fuel it holds -- desc: Fat Cow Hellfires (x4) - weight: 250 - item: weapons.missiles.AGM_114K # DCS warehouse item name - count: 4 +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 ``` -An entry has either `fuelKg`, or `item` and `count`. -`item` is the name DCS gives the weapon in a warehouse: `weapons.missiles.AGM_114K` was checked in game for the Hellfire. -The 30 mm entry (`weapons.shells.M230_HEDP M789`) is the name DCS gives the Apache's rounds, not yet checked as a warehouse item. +A player cow finds them under **Load**, in a logistic zone; a flight requesting an AI cow picks one under **Request a Fat Cow**. +The four defaults all carry two 800 gal tanks (4 800 kg), with 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. +They weigh 7 800 to 8 200 kg, 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 %. -Cow crates never show in **Request Equipment** nor in **Unpack Crate**: they are requested from the Fat Cow menu, and deployed from the hold. +`item` is the name DCS gives the weapon in a warehouse. +`weapons.missiles.AGM_114K` was checked in game for the laser Hellfire. +`weapons.missiles.AGM_114` (the AGM-114L, from the DCS data mine) and `weapons.shells.M230_HEDP M789` (the Apache's rounds) are not yet checked as warehouse items. -### Loadouts +### Cow crates -Each `fatCowLoadouts` entry is one **Load: ** command: its crates, named by their `fatCowCrates` desc, go straight into the hold, in a logistic zone. +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 -fatCowLoadouts: -- desc: 4 Hellfires + max fuel - crates: - - crate: Fat Cow fuel tank (800 gal) - count: 3 - - crate: Fat Cow Hellfires (x4) - count: 1 +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 ``` -A loadout that would exceed the aircraft's `maxCratesOnboard` (8 for the CH-47F) is refused. -The default "Max weapons + max fuel" is about 8 100 kg: what a CH-47F full of internal fuel can lift (22 680 kg at most, 11 400 kg empty, 3 054 kg of fuel, from DCS's units database). +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) and 1 200 rounds of 30 mm (600 kg, 0.5 kg a round with its packing: an estimate). +Every default loadout fits in 6 or 7 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 @@ -89,6 +105,7 @@ To give a flight its cow from the start instead, place the cow in the Mission Ed 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. @@ -97,5 +114,5 @@ If it is a client flight, its players command the cow from F10, as a requested o 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 }`. +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 index 19061b38..587aa7dc 100644 --- a/docs/pilot/fat-cow.fr.md +++ b/docs/pilot/fat-cow.fr.md @@ -14,8 +14,10 @@ Tout se trouve dans **F10 → CTLD → Fat Cow**. 1. Posez-vous dans une zone logistique. 2. Chargez ce que vous voulez emporter : - - **Charger : Plein carburant** (3 réservoirs, 7 200 kg), **Charger : 4 Hellfire + carburant max** ou **Charger : Armement max + carburant max** (3 réservoirs, 12 Hellfire, 300 obus de 30 mm, environ 8 100 kg) : les caisses vont directement en soute ; - - ou une caisse à la fois, **Demander Réservoir Fat Cow (800 gal)** (2 400 kg de carburant jet, et 2 400 kg dans votre soute), **Demander Hellfire Fat Cow (x4)**, **Demander Fat Cow 30 mm (x300)**, puis chargez-les comme n'importe quelle caisse CTLD. + - **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 au choix : 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. + Cela fait de 7 800 à 8 200 kg en 6 ou 7 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, 20 Hellfire radar ou 1 200 obus de 30 mm, 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. @@ -35,7 +37,7 @@ Si vous quittez votre slot pendant que la FARP est ouverte, la FARP reste, avec ## Demander une cow IA -Tout joueur dont l'appareil n'est pas une cow peut **Demander une Fat Cow**, au sol comme en vol. +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 : @@ -52,7 +54,7 @@ Vous pouvez donc redécoller pour vous déplacer en stationnaire jusqu'à sa FAR **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. -Par défaut, une cow IA emporte 7 200 kg de carburant (trois réservoirs de 800 gal) et 6 Hellfire. +**Demander une Fat Cow** ouvre la liste des lots, les mêmes que ceux d'une cow pilotée : choisissez ce dont votre patrouille a besoin. Une cow par patrouille. !!! tip "Posez-vous à découvert" diff --git a/docs/pilot/fat-cow.md b/docs/pilot/fat-cow.md index 9f99b7ca..ecf04094 100644 --- a/docs/pilot/fat-cow.md +++ b/docs/pilot/fat-cow.md @@ -14,8 +14,10 @@ Everything is in **F10 → CTLD → Fat Cow**. 1. Land in a logistic zone. 2. Load what you want to carry: - - **Load: Full fuel** (3 tanks, 7 200 kg), **Load: 4 Hellfires + max fuel** or **Load: Max weapons + max fuel** (3 tanks, 12 Hellfires, 300 rounds of 30 mm, about 8 100 kg): the crates go straight into the hold; - - or one crate at a time, **Request Fat Cow fuel tank (800 gal)** (2 400 kg of jet fuel, and 2 400 kg in your hold), **Request Fat Cow Hellfires (x4)**, **Request Fat Cow 30 mm (x300)**, then load them like any CTLD crate. + - **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 one of: 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. + That is 7 800 to 8 200 kg in 6 or 7 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 Hellfires, 20 radar Hellfires or 1 200 rounds of 30 mm, 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. @@ -35,7 +37,7 @@ If you leave your slot while the FARP is open, the FARP stays, with what it stil ## 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. +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: @@ -52,7 +54,7 @@ So you can lift off to hover-taxi onto its FARP, or reposition, without the cow **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. -By default an AI cow carries 7 200 kg of fuel (three 800 gal tanks) and 6 Hellfires. +**Request a Fat Cow** opens the list of loadouts, the same as a piloted cow's: pick what your flight needs. One cow per flight. !!! tip "Land in the open" diff --git a/src/CTLD_config.yaml b/src/CTLD_config.yaml index 79aaab8f..baf18894 100644 --- a/src/CTLD_config.yaml +++ b/src/CTLD_config.yaml @@ -277,44 +277,58 @@ mm_facing: farpTroopPickupRadius: 150 fastRopeMaximumHeight: 18.28 fatCowAICows: [] - fatCowAIFuelKg: 7200 - fatCowAIItems: - - item: weapons.missiles.AGM_114K - count: 6 fatCowAIType: CH-47Fbl1 fatCowCrates: - desc: Fat Cow fuel tank (800 gal) weight: 2400 fuelKg: 2400 - - desc: Fat Cow Hellfires (x4) - weight: 250 + - desc: Fat Cow laser Hellfires (x20) + weight: 906 item: weapons.missiles.AGM_114K - count: 4 - - desc: Fat Cow 30 mm (x300) - weight: 150 + 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: 300 + count: 1200 fatCowDeployDelay: 60 fatCowEnabled: true fatCowLoadouts: - - desc: Full fuel - crates: - - crate: Fat Cow fuel tank (800 gal) - count: 3 - - desc: 4 Hellfires + max fuel - crates: - - crate: Fat Cow fuel tank (800 gal) - count: 3 - - crate: Fat Cow Hellfires (x4) - count: 1 - - desc: Max weapons + max fuel - crates: - - crate: Fat Cow fuel tank (800 gal) - count: 3 - - crate: Fat Cow Hellfires (x4) - count: 3 - - crate: Fat Cow 30 mm (x300) - count: 1 + - 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 fatCowTypes: - CH-47Fbl1 fobCrateCollectionRadius: 750 diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index 1919fa59..1673dfd6 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -471,14 +471,14 @@ fatCowCrates: group: fob label: {en: Fat Cow crates, fr: Caisses Fat Cow} description: - en: 'What a player cow can load: each crate has a desc, a weight (kg in the hold, and its catalogue key) and either fuelKg (jet fuel) or an item (a DCS warehouse item name) with its count. Default: an 800 gal ERFS tank of 2400 kg, 4 Hellfires, 300 rounds of 30 mm.' - fr: "Ce qu'une cow pilotée peut charger : chaque caisse a un desc, un poids (kg en soute, et sa clé dans le catalogue) et soit fuelKg (carburant jet), soit un item (nom d'article d'entrepôt DCS) avec son nombre. Par défaut : un réservoir ERFS de 800 gal de 2400 kg, 4 Hellfire, 300 obus de 30 mm." + 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 Hellfires, 20 radar Hellfires, 1200 rounds of 30 mm.' + 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, 20 Hellfire radar, 1200 obus de 30 mm." fatCowLoadouts: group: fob label: {en: Fat Cow loadouts, fr: Lots de chargement Fat Cow} description: - en: 'What a player cow can load at once, straight into the hold, in a logistic zone: each loadout has a desc and a list of crates, each a fatCowCrates desc with its count. Default: full fuel; 4 Hellfires + max fuel; max weapons + max fuel (about 8 100 kg, what a CH-47F full of internal fuel can lift, in 7 of its 8 crate slots).' - fr: "Ce qu'une cow pilotée peut charger d'un coup, directement en soute, dans une zone logistique : chaque lot a un desc et une liste de caisses, chacune un desc de fatCowCrates avec son nombre. Par défaut : plein carburant ; 4 Hellfire + carburant max ; armement max + carburant max (environ 8 100 kg, ce qu'un CH-47F plein de carburant interne peut emporter, sur 7 de ses 8 places de caisse)." + 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) and a list of items (DCS warehouse item name and count). Default: two 800 gal tanks (4800 kg) with 40 Hellfires (25 laser, 15 radar) and 2400 rounds, 60 Hellfires (38 laser, 22 radar) and 1200 rounds, 40 laser Hellfires and 2400 rounds, or 60 laser Hellfires and 1200 rounds: 7800 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) et une liste d'items (nom d'article d'entrepôt DCS et nombre). Par défaut : deux réservoirs de 800 gal (4800 kg) avec 40 Hellfire (25 laser, 15 radar) et 2400 obus, 60 Hellfire (38 laser, 22 radar) et 1200 obus, 40 Hellfire laser et 2400 obus, ou 60 Hellfire laser et 1200 obus : de 7800 à 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} @@ -492,25 +492,12 @@ fatCowAIType: 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. -fatCowAIFuelKg: - group: fob - label: {en: AI Fat Cow fuel, fr: Carburant de la cow IA} - unit: kg - description: - en: Jet fuel an AI cow carries for its FARP. Default 7200 kg, three 800 gal ERFS tanks. - fr: Carburant jet qu'une cow IA emporte pour sa FARP. Par défaut 7200 kg, trois réservoirs ERFS de 800 gal. -fatCowAIItems: - group: fob - label: {en: AI Fat Cow ammunition, fr: Munitions de la cow IA} - description: - en: 'Ammunition an AI cow carries, each an item (DCS warehouse item name) and a count. Default: 6 Hellfires (weapons.missiles.AGM_114K).' - fr: "Munitions qu'une cow IA emporte, chacune un item (nom d'article d'entrepôt DCS) et un nombre. Par défaut : 6 Hellfire (weapons.missiles.AGM_114K)." 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) and the flight it escorts (escort). 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) et la patrouille qu'il escorte (escort). CTLD active la cow dès que la patrouille est en jeu." + 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_fatCow.lua b/src/CTLD_fatCow.lua index 80871507..d6aeb104 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -49,8 +49,18 @@ function CTLDFatCow:isEmpty() return true end ---- Writes the stock into a FARP warehouse: jet fuel, every other liquid at 0, each stocked item. +--- 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 +-- nothing else (a weapon someone else put there is removed). 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) @@ -60,11 +70,30 @@ function CTLDFatCow:writeWarehouse(w) end end ---- Reads the stock back from the FARP warehouse: what the players took is gone. -function CTLDFatCow:readWarehouse(w) - self.stock.fuelKg = w:getLiquidAmount(JET_FUEL) or 0 +--- 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 - self.stock.items[name] = w:getItemCount(name) or 0 + 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 @@ -88,14 +117,23 @@ local function isCowCrate(crate) return crate and crate.descriptor and crate.descriptor.fatCow ~= nil end ---- "2400 kg of fuel, AGM_114K x4" — for the players. +--- 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 + 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", (name:gsub("^weapons%.[%a_]+%.", "")), stock.items[name]) + parts[#parts + 1] = string.format("%s x%d", itemLabel(name), stock.items[name]) end return table.concat(parts, ", ") end @@ -167,7 +205,32 @@ function CTLDFatCowManager.getInstance() end function CTLDFatCowManager:init() - self._cows = {} -- unit name → CTLDFatCow + 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). @@ -204,11 +267,14 @@ function CTLDFatCowManager:deploy(unit) 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 }) + 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 @@ -223,6 +289,7 @@ function CTLDFatCowManager:onSceneLaidOut(scene) 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) @@ -232,6 +299,17 @@ function CTLDFatCowManager:onSceneLaidOut(scene) self:_notify(cow, ctld.tr("Fat Cow FARP open: %1", CTLDFatCow.describeStock(cow.stock))) end +--- Called by the scene when its FARP could not be added: the cow is stowed again. +function CTLDFatCowManager:onSceneFailed(scene) + local cow = scene._params and scene._params.fatCow + if not cow then return end + CTLDSceneManager.getInstance():packScene(scene) + 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 @@ -239,13 +317,23 @@ function CTLDFatCowManager:_notify(cow, text) end end ---- Reads what the players left back into the stock, then removes the scene. +--- 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 - local w = warehouseOf(cow.farpName) - if w then cow:readWarehouse(w) 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 @@ -260,17 +348,43 @@ function CTLDFatCowManager:pack(unitName) return true end ---- S_EVENT_BIRTH: a player arriving gets every open FARP's warehouse re-written (see resyncWarehouse). +--- 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 + 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 @@ -291,6 +405,11 @@ function CTLDFatCowManager:start() 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 @@ -320,13 +439,15 @@ end function CTLDFatCowManager:_injectCrateDescriptors() local cm = CTLDCrateManager.getInstance() self._crateDescs = {} + self._kgPerItem = {} for _, def in ipairs(ctld.gs("fatCowCrates") or {}) do - local content = (def.fuelKg and { fuelKg = def.fuelKg }) - or (def.item and def.count and { item = def.item, count = def.count }) + 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), @@ -334,7 +455,6 @@ function CTLDFatCowManager:_injectCrateDescriptors() cratesRequired = 1, showSets = false, fatCow = content, - fatCowKey = def.desc, -- the untranslated name loadouts refer to }) self._crateDescs[#self._crateDescs + 1] = d end @@ -402,24 +522,52 @@ local function atLogistics(t, gid) return true end ---- F10 "Load: ": every crate of a fatCowLoadouts entry, straight into the hold. +--- 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 byKey = {} - for _, d in ipairs(self._crateDescs or {}) do byKey[d.fatCowKey] = d end - local wanted = {} - for _, line in ipairs(loadout.crates or {}) do - local d = byKey[line.crate] - if not d then - ctld.utils.log("WARN", "CTLDFatCowManager: loadout '%s' names an unknown crate '%s'", tostring(loadout.desc), tostring(line.crate)) - trigger.action.outTextForGroup(gid, ctld.tr("This loadout cannot be loaded."), 10) - return - end - for _ = 1, (line.count or 1) do wanted[#wanted + 1] = d 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 {} @@ -431,7 +579,15 @@ function CTLDFatCowManager:loadLoadout(unitName, index) return end local loaded = {} - for _, d in ipairs(wanted) do loaded[#loaded + 1] = cm:loadIntoHold(d, t, unitName) end + 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 @@ -479,6 +635,7 @@ function CTLDFatCowManager:deployFromHold(unitName) 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) @@ -550,8 +707,10 @@ end 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 @@ -584,16 +743,19 @@ function CTLDFatCowManager:buildMenuSection(playerObj, menu) 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 }) + for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do + menu:addCommand(loadPath, ctld.tr(l.desc or "?"), + function(arg) mgr():loadLoadout(arg.unitName, arg.index) end, + { unitName = playerObj.unitName, index = i }, { order = i }) + end + menu:addSubMenu(path, cratePath[3], { order = 2 }) for i, d in ipairs(self._crateDescs or {}) do - menu:addCommand(path, ctld.tr("Request %1", d.desc), + menu:addCommand(cratePath, d.desc, function(arg) mgr():requestCrate(arg.unitName, arg.weight) end, { unitName = playerObj.unitName, weight = d.weight }, { order = i }) end - for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do - menu:addCommand(path, ctld.tr("Load: %1", ctld.tr(l.desc or "?")), - function(arg) mgr():loadLoadout(arg.unitName, arg.index) end, - { unitName = playerObj.unitName, index = i }, { order = 10 + 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"), @@ -632,9 +794,10 @@ CTLDFatCowManager.PHASE_LANDED = "landed" -- on the ground, deployment p 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: the CH-47Fbl1's M_fuel_max in DCS's db.Units.lua. --- A scripted aircraft created without fuel falls out of the sky. -local AI_COW_FUEL_KG = 3054 +-- 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 @@ -646,10 +809,13 @@ local AI_COW_CRUISE_MPS = 65 -- The flight's take-off or a release re-tasks it long before. local LAND_DURATION_S = 86400 -local function aiStockFromConfig() - local stock = { fuelKg = ctld.gs("fatCowAIFuelKg"), items = {} } - for _, it in ipairs(ctld.gs("fatCowAIItems") or {}) do - if it.item and it.count then stock.items[it.item] = it.count end +--- 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 @@ -682,20 +848,30 @@ local function landMission(point) } 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) - local kg = cow.stock.fuelKg or 0 - for _, count in pairs(cow.stock.items) do kg = kg + 50 * count end -- ~50 kg a missile - pcall(trigger.action.setUnitInternalCargo, cow.unitName, kg) + 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 -function CTLDFatCowManager:addAICow(groupName, flightGroup, homePoint) - local unit = firstUnitOf(groupName) - if not unit then return nil end - local cow = self:addCow(unit, aiStockFromConfig()) +-- @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 {} @@ -785,6 +961,7 @@ function CTLDFatCowManager:onLand(event) 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 @@ -931,9 +1108,9 @@ local function etaMinutes(distanceM) return math.max(1, math.floor(distanceM / AI_COW_CRUISE_MPS / 60 + 0.5)) end ---- F10 "Request a Fat Cow", from anywhere: it takes off from the friendly logistic zone nearest to --- the requester and joins the flight. One cow per flight. -function CTLDFatCowManager:requestAICow(unitName) +--- 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) @@ -942,6 +1119,7 @@ function CTLDFatCowManager:requestAICow(unitName) 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 @@ -978,7 +1156,7 @@ function CTLDFatCowManager:requestAICow(unitName) 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) + 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, @@ -1004,7 +1182,13 @@ function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) menu:addSubMenu({ path[1] }, path[2], { order = 50 }) local mgr = CTLDFatCowManager.getInstance local u = { unitName = playerObj.unitName } - menu:addCommand(path, ctld.tr("Request a Fat Cow"), function(arg) mgr():requestAICow(arg.unitName) end, u, { order = 1 }) + local requestPath = { path[1], path[2], ctld.tr("Request a Fat Cow") } + menu:addSubMenu(path, requestPath[3], { order = 1 }) + for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do + menu:addCommand(requestPath, ctld.tr(l.desc or "?"), + function(arg) mgr():requestAICow(arg.unitName, arg.index) end, + { unitName = playerObj.unitName, index = i }, { order = i }) + end 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 }) @@ -1082,7 +1266,8 @@ function CTLDFatCowManager:reportFarpStatus(unitName) end -- ── Mission-placed cows (fatCowAICows): the VMCT contract ────────────────────────────────────── --- Each entry { group = "", escort = "" }. CTLD activates +-- 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 @@ -1100,11 +1285,17 @@ function CTLDFatCowManager:_activateMissionCow(entry) 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) end, nil, timer.getTime() + 1) + ctld.scheduler.schedule(function() mgr:addAICow(entry.group, entry.escort, home, entry.loadout or 1) end, + nil, timer.getTime() + 1) return true end diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index 5e506144..ec923f26 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.32" +ctld.i18n["en"].translation_version = "1.33" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -619,18 +619,15 @@ 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 30 mm (x300)"] = "Fat Cow 30 mm (x300)" 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 Hellfires (x4)"] = "Fat Cow Hellfires (x4)" 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 %1"] = "Request %1" 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." @@ -641,7 +638,6 @@ 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"]["4 Hellfires + max fuel"] = "4 Hellfires + max fuel" 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." @@ -651,14 +647,11 @@ ctld.i18n["en"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Lan 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"]["Full fuel"] = "Full fuel" 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"]["Load: %1"] = "Load: %1" ctld.i18n["en"]["Loaded into your hold: %1"] = "Loaded into your hold: %1" -ctld.i18n["en"]["Max weapons + max fuel"] = "Max weapons + max fuel" 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." @@ -679,3 +672,17 @@ 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" diff --git a/src/CTLD_i18n_es.lua b/src/CTLD_i18n_es.lua index 9f738ea5..85a92292 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.32" +ctld.i18n["es"].translation_version = "1.33" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -626,18 +626,15 @@ 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 30 mm (x300)"] = "Fat Cow 30 mm (x300)" 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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" 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 %1"] = "Solicitar %1" 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." @@ -648,7 +645,6 @@ ctld.i18n["es"]["Your flight has no Fat Cow."] = "Tu escuadrilla no tiene Fat Co --- 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"]["4 Hellfires + max fuel"] = "4 Hellfire + combustible máx." 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." @@ -658,14 +654,11 @@ ctld.i18n["es"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Lan 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"]["Full fuel"] = "Combustible completo" 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"]["Load: %1"] = "Cargar: %1" ctld.i18n["es"]["Loaded into your hold: %1"] = "Cargado en la bodega: %1" -ctld.i18n["es"]["Max weapons + max fuel"] = "Armamento máx. + combustible máx." 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." @@ -686,3 +679,17 @@ 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" diff --git a/src/CTLD_i18n_fr.lua b/src/CTLD_i18n_fr.lua index abebddfe..e87ce3bd 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.32" +ctld.i18n["fr"].translation_version = "1.33" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -651,18 +651,15 @@ 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 30 mm (x300)"] = "Fat Cow 30 mm (x300)" 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 Hellfires (x4)"] = "Hellfire Fat Cow (x4)" 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 %1"] = "Demander %1" 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." @@ -673,7 +670,6 @@ ctld.i18n["fr"]["Your flight has no Fat Cow."] = "Votre patrouille n'a pas de Fa --- 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"]["4 Hellfires + max fuel"] = "4 Hellfire + carburant max" 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." @@ -683,14 +679,11 @@ ctld.i18n["fr"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Lan 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"]["Full fuel"] = "Plein carburant" 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"]["Load: %1"] = "Charger : %1" ctld.i18n["fr"]["Loaded into your hold: %1"] = "Chargé en soute : %1" -ctld.i18n["fr"]["Max weapons + max fuel"] = "Armement max + carburant max" 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." @@ -711,3 +704,17 @@ 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" diff --git a/src/CTLD_i18n_ko.lua b/src/CTLD_i18n_ko.lua index 281f1994..4f12d1f8 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.32" +ctld.i18n["ko"].translation_version = "1.33" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -508,18 +508,15 @@ 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 30 mm (x300)"] = "Fat Cow 30mm (x300)" 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 Hellfires (x4)"] = "Fat Cow 헬파이어 (x4)" 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 %1"] = "%1 요청" 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 화물이 비어 있습니다." @@ -530,7 +527,6 @@ 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"]["4 Hellfires + max fuel"] = "헬파이어 4발 + 최대 연료" 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가 당신 위치 상공에서 대기합니다." @@ -540,14 +536,11 @@ ctld.i18n["ko"]["Fat Cow on its way from %1: %2 km, joining in about %3 min. Lan 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"]["Full fuel"] = "연료 만재" 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"]["Load: %1"] = "적재: %1" ctld.i18n["ko"]["Loaded into your hold: %1"] = "화물칸 적재 완료: %1" -ctld.i18n["ko"]["Max weapons + max fuel"] = "최대 무장 + 최대 연료" 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개." @@ -568,3 +561,17 @@ 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"] = "상자 요청" diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua index 2e329ebe..e136e240 100644 --- a/src/scenes/CTLD_fatCowScene.lua +++ b/src/scenes/CTLD_fatCowScene.lua @@ -7,7 +7,8 @@ -- 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 +-- 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 @@ -30,16 +31,20 @@ fatCowScene.DRESSING = { fatCowScene.steps = { - -- Step 1: the Invisible FARP at the cow. Its name is what the warehouse is looked up by. + -- 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. { - polar = { distance = 0, angle = 0 }, - delayAfterPreviousStep = 0, - relativeHeadingInDegrees = 0, - relativeAltitudeInMeters = 0, - registryKey = "Invisible_FARP", - critical = true, + delayAfterPreviousStep = 0, func = function(ctx) - ctx.scene._params.farpName = ctx.spawnedObj:getName() + 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, }, diff --git a/tests/ci/data/catalogue_shapes/2.3.0.json b/tests/ci/data/catalogue_shapes/2.3.0.json index b0dc0254..b6c253df 100644 --- a/tests/ci/data/catalogue_shapes/2.3.0.json +++ b/tests/ci/data/catalogue_shapes/2.3.0.json @@ -82,8 +82,6 @@ "farpTroopPickupRadius", "fastRopeMaximumHeight", "fatCowAICows", - "fatCowAIFuelKg", - "fatCowAIItems", "fatCowAIType", "fatCowCrates", "fatCowDeployDelay", diff --git a/tests/ci/data/config_defaults.json b/tests/ci/data/config_defaults.json index aaa3309f..684a7962 100644 --- a/tests/ci/data/config_defaults.json +++ b/tests/ci/data/config_defaults.json @@ -351,13 +351,6 @@ "farpTroopPickupRadius": 150, "fastRopeMaximumHeight": 18.28, "fatCowAICows": [], - "fatCowAIFuelKg": 7200, - "fatCowAIItems": [ - { - "count": 6, - "item": "weapons.missiles.AGM_114K" - } - ], "fatCowAIType": "CH-47Fbl1", "fatCowCrates": [ { @@ -366,59 +359,90 @@ "weight": 2400 }, { - "count": 4, - "desc": "Fat Cow Hellfires (x4)", + "count": 20, + "desc": "Fat Cow laser Hellfires (x20)", "item": "weapons.missiles.AGM_114K", - "weight": 250 + "weight": 906 + }, + { + "count": 20, + "desc": "Fat Cow radar Hellfires (x20)", + "item": "weapons.missiles.AGM_114", + "weight": 980 }, { - "count": 300, - "desc": "Fat Cow 30 mm (x300)", + "count": 1200, + "desc": "Fat Cow 30 mm (x1200)", "item": "weapons.shells.M230_HEDP M789", - "weight": 150 + "weight": 600 } ], "fatCowDeployDelay": 60, "fatCowEnabled": true, "fatCowLoadouts": [ { - "crates": [ + "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": 3, - "crate": "Fat Cow fuel tank (800 gal)" + "count": 2400, + "item": "weapons.shells.M230_HEDP M789" } - ], - "desc": "Full fuel" + ] }, { - "crates": [ + "desc": "60 Hellfires (38 laser, 22 radar), 1200 rounds", + "fuelKg": 4800, + "items": [ + { + "count": 38, + "item": "weapons.missiles.AGM_114K" + }, { - "count": 3, - "crate": "Fat Cow fuel tank (800 gal)" + "count": 22, + "item": "weapons.missiles.AGM_114" }, { - "count": 1, - "crate": "Fat Cow Hellfires (x4)" + "count": 1200, + "item": "weapons.shells.M230_HEDP M789" } - ], - "desc": "4 Hellfires + max fuel" + ] }, { - "crates": [ + "desc": "40 laser Hellfires, 2400 rounds", + "fuelKg": 4800, + "items": [ { - "count": 3, - "crate": "Fat Cow fuel tank (800 gal)" + "count": 40, + "item": "weapons.missiles.AGM_114K" }, { - "count": 3, - "crate": "Fat Cow Hellfires (x4)" + "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": 1, - "crate": "Fat Cow 30 mm (x300)" + "count": 1200, + "item": "weapons.shells.M230_HEDP M789" } - ], - "desc": "Max weapons + max fuel" + ] } ], "fatCowTypes": [ diff --git a/tests/ci/unit/fatcow_ai_spec.lua b/tests/ci/unit/fatcow_ai_spec.lua index 5381d71f..4055d01f 100644 --- a/tests/ci/unit/fatcow_ai_spec.lua +++ b/tests/ci/unit/fatcow_ai_spec.lua @@ -6,6 +6,7 @@ -- ============================================================ 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 @@ -96,14 +97,16 @@ describe("AI Fat Cow", function() end sm.playScene = function(_, u, model, params) local scene = { _name = "Fat Cow FARP#1", _params = params } - params.farpName = "FatCow_FARP-1" mgr:onSceneLaidOut(scene) return scene end sm.packScene = function() return {} end - Airbase.getByName = function(n) if n == "FatCow_FARP-1" then return { getWarehouse = function() return w end } end end - local settings = { fatCowAIType = "CH-47Fbl1", fatCowAIFuelKg = 7200, fatCowDeployDelay = 60, - fatCowAIItems = { { item = HELLFIRE, count = 6 } }, fatCowAICows = {} } + 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) @@ -124,7 +127,7 @@ describe("AI Fat Cow", function() 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") + mgr:requestAICow("Gunfighter-1", 1) runScheduled() local cow = mgr:aiCowOfFlight("Gunfighter") return leader, cow @@ -137,7 +140,7 @@ describe("AI Fat Cow", function() 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.is_true(added[1].units[1].payload.fuel > 0) + 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]) @@ -146,17 +149,30 @@ describe("AI Fat Cow", function() 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") + mgr:requestAICow("Gunfighter-1", 1) assert.equals(0, #added) inZone = true - mgr:requestAICow("Gunfighter-1") + mgr:requestAICow("Gunfighter-1", 1) runScheduled() - mgr:requestAICow("Gunfighter-1") + mgr:requestAICow("Gunfighter-1", 1) assert.equals(1, #added) assert.is_not_nil(messages[2].text:find("Akrotiri", 1, true)) end) @@ -202,7 +218,7 @@ describe("AI Fat Cow", function() 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("AGM_114K x6", 1, true)) + assert.is_not_nil(text:find("laser Hellfire x6", 1, true)) assert.equals(CTLDFatCow.STATE_STOWED, cow.state) end) @@ -322,6 +338,39 @@ describe("AI Fat Cow", function() 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 index 8a8b6d7e..6f177e78 100644 --- a/tests/ci/unit/fatcow_core_spec.lua +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -10,6 +10,11 @@ local function newWarehouse() 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 @@ -80,6 +85,32 @@ describe("CTLDFatCow stock", function() 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 ─────────────────────────────────────────────────────────────────────────── @@ -105,12 +136,13 @@ describe("Fat Cow FARP scene", function() 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 = {}, {}, nil + 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) + 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 - return obj(key .. "-1") + 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 @@ -120,7 +152,7 @@ describe("Fat Cow FARP scene", function() 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, } - CTLDSceneManager.getInstance():playScene(unit, "Fat Cow FARP", {}) + 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 @@ -135,7 +167,28 @@ describe("Fat Cow FARP scene", function() assert.equals("CTLD_", sd.name:sub(1, 5)) -- never registered as a loadable crate end assert.is_not_nil(laidOut) - assert.equals("Invisible_FARP-1", laidOut._params.farpName) + 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 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) @@ -167,13 +220,12 @@ describe("CTLDFatCowManager deploy / pack", function() sm.playScene = function(_, unit, modelName, params) played = { unit = unit, model = modelName, params = params } local scene = { _name = "Fat Cow FARP#1", _params = params } - params.farpName = "FatCow_FARP-1" mgr:onSceneLaidOut(scene) return scene end sm.packScene = function(_, scene) packed = scene; return {} end Airbase.getByName = function(name) - if name == "FatCow_FARP-1" then return { getWarehouse = function() return w end } end + if name:find("^CTLD_FatCow_FARP_") then return { getWarehouse = function() return w end } end end end) @@ -191,7 +243,20 @@ describe("CTLDFatCowManager deploy / pack", function() assert.equals(6, w.items[HELLFIRE]) local cow = mgr:getCow("Cow-1") assert.equals(CTLDFatCow.STATE_DEPLOYED, cow.state) - assert.equals("FatCow_FARP-1", cow.farpName) + 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() @@ -231,6 +296,8 @@ describe("CTLDFatCowManager deploy / pack", function() 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() @@ -258,9 +325,63 @@ describe("CTLDFatCowManager deploy / pack", function() 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("removing a deployed cow packs it first", function() diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua index 01a9b1a4..0f5ce3e7 100644 --- a/tests/ci/unit/fatcow_piloted_spec.lua +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -100,12 +100,11 @@ describe("Piloted cow commands", function() cm.crates = {} sm.playScene = function(_, u, model, params) local scene = { _name = "Fat Cow FARP#1", _params = params } - params.farpName = "FatCow_FARP-1" mgr:onSceneLaidOut(scene) return scene end sm.packScene = function() return {} end - Airbase.getByName = function(n) if n == "FatCow_FARP-1" then return { getWarehouse = function() return w end } end end + Airbase.getByName = function(n) if n == "CTLD_FatCow_FARP_1" then return { getWarehouse = function() return w end } end end end) after_each(function() @@ -231,24 +230,55 @@ describe("Fat Cow loadouts", function() CTLDFatCowManager._instance = nil end) - local function indexOf(desc) - for i, l in ipairs(ctld.gs("fatCowLoadouts")) do if l.desc == desc then return i end end - 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("puts a whole loadout straight into the hold", function() - mgr:loadLoadout("Cow-1", indexOf("Max weapons + max fuel")) + 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) + assert.equals(7, #onboard) -- 2 tanks, 20 + 5 laser, 15 radar, 2 x 1200 rounds local stock = CTLDFatCow.stockOfCrates(onboard) - assert.equals(7200, stock.fuelKg) - assert.equals(12, stock.items[HELLFIRE]) - assert.equals(300, stock.items["weapons.shells.M230_HEDP M789"]) - for _, c in ipairs(onboard) do assert.is_nil(c.dcsStatic) end -- nothing left on the ground + 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("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", indexOf("Max weapons + max fuel")) - mgr:loadLoadout("Cow-1", indexOf("Full fuel")) -- 7 + 3 > 8 + 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") @@ -256,7 +286,7 @@ describe("Fat Cow loadouts", function() end) it("unloads the cow crates in a logistic zone", function() - mgr:loadLoadout("Cow-1", indexOf("Full fuel")) + mgr:loadLoadout("Cow-1", 4) mgr:unloadHold("Cow-1") assert.equals(0, #cm:cratesOnboard("Cow-1")) end) From b329028c8097d1584280fc8b638bf430ae0b9142 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Tue, 27 Oct 2026 09:45:16 +0100 Subject: [PATCH 35/36] feat(fat-cow): Russian and mixed loadouts, one CH-47 for everyone Lot 2 (Zip): the cow stays a CH-47 whatever the requester flies; Russian and mixed loadouts are added in Russian and Mixed submenus of Load and Request a Fat Cow (new optional `menu` field of a loadout). - crates: 20 Vikhr, 16 Ataka, 16 Shturm, 460 rounds of 2A42 HE and AP, 500 rounds of GSh-30 (names and missile masses from the DCS data mine) - loadouts: Ka-50 (40 or 60 Vikhr with 2A42), Mi-24P (32 Ataka or 32 Shturm with GSh-30), two Apache + Russian mixes; all with two 800 gal tanks, 6 400 to 8 200 kg, 5 to 8 crates - labels for the new items; docs, CHANGELOG, ticket 06 step C6, roadmap Co-Authored-By: Claude Opus 5.5 --- .../FEAT-FAT-COW/tickets/06-live-check.md | 1 + CHANGELOG.md | 3 +- dev/roadmap.md | 8 +- docs/mission-maker/fat-cow.fr.md | 32 +++- docs/mission-maker/fat-cow.md | 32 +++- docs/pilot/fat-cow.fr.md | 16 +- docs/pilot/fat-cow.md | 16 +- src/CTLD_config.yaml | 88 ++++++++++ src/CTLD_config_schema.yaml | 8 +- src/CTLD_fatCow.lua | 46 +++-- src/CTLD_i18n_en.lua | 24 ++- src/CTLD_i18n_es.lua | 24 ++- src/CTLD_i18n_fr.lua | 24 ++- src/CTLD_i18n_ko.lua | 24 ++- tests/ci/data/config_defaults.json | 158 ++++++++++++++++++ tests/ci/unit/fatcow_piloted_spec.lua | 21 +++ 16 files changed, 479 insertions(+), 46 deletions(-) diff --git a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md index b9e25403..4873a6ed 100644 --- a/.backlog/FEAT-FAT-COW/tickets/06-live-check.md +++ b/.backlog/FEAT-FAT-COW/tickets/06-live-check.md @@ -96,6 +96,7 @@ Fixed on 2026-10-27, in this PR (Zip's lot 1): 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 51895a99..c3ee704c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ Versioning follows [Semantic Versioning](https://semver.org/). 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 four default loadouts carry two tanks (4 800 kg) with 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: 7 800 to 8 200 kg, what a CH-47F lifts with 80 % of its internal fuel. + 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. diff --git a/dev/roadmap.md b/dev/roadmap.md index cddb0517..422b1d33 100644 --- a/dev/roadmap.md +++ b/dev/roadmap.md @@ -286,11 +286,9 @@ Quatre tests (`test_install`, `test_inject_into_miz`) échouent en local quand ` 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 — profils pour les hélicos russes (lot 2) - -Décidé par Zip le 2026-10-27, après FEAT-FAT-COW : des lots de chargement pour Ka-50 (Vikhr `Vikhr_M`, 45 kg) et Mi-24P (Ataka `Ataka_9M120`, 49,5 kg ; Shturm `AT_6`, 35,4 kg ; 30 mm 2A42 / GSh-30-2K), portés par une cow IA **Mi-26** (IA seulement dans DCS ; 56 000 kg max, 28 890 kg à vide, 9 600 kg de carburant, soit 19 430 kg de charge utile à 80 % de carburant). -Le réel : le Mi-26TZ est la vraie cow russe. -À cadrer : le choix du type de cow et des lots selon l'appareil du demandeur (un pilote de Ka-50 ne voit pas les lots Apache), et la mesure en jeu des noms d'entrepôt (`weapons.missiles.Vikhr_M`, `Ataka_9M120`, `AT_6`, obus `2A42_30_HE`…), aucun n'étant vérifié. + ## Fat Cow — Mi-8 piloté en « petite cow » (lot 3, optionnel) diff --git a/docs/mission-maker/fat-cow.fr.md b/docs/mission-maker/fat-cow.fr.md index a00ee48c..d6d26817 100644 --- a/docs/mission-maker/fat-cow.fr.md +++ b/docs/mission-maker/fat-cow.fr.md @@ -39,8 +39,8 @@ Tous dans la famille `fob` de la configuration. |---|---|---| | `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, 20 Hellfire laser, 20 Hellfire radar, 1 200 obus de 30 mm | Les caisses dans lesquelles une cow pilotée emporte son stock (voir plus bas). | -| `fatCowLoadouts` | quatre lots Hellfire et 30 mm, 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). | +| `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). | @@ -63,12 +63,30 @@ fatCowLoadouts: ``` 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**. -Les quatre lots par défaut emportent tous deux réservoirs de 800 gal (4 800 kg), avec 40 Hellfire (25 laser, 15 radar) et 2 400 obus, 60 Hellfire (38 laser, 22 radar) et 1 200 obus, 40 Hellfire laser et 2 400 obus, ou 60 Hellfire laser et 1 200 obus. -Ils pèsent de 7 800 à 8 200 kg, 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 %. +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. -`weapons.missiles.AGM_114` (l'AGM-114L, d'après le data mine DCS) et `weapons.shells.M230_HEDP M789` (les obus de l'Apache) ne sont pas encore vérifiés comme articles d'entrepôt. +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 @@ -86,8 +104,8 @@ fatCowCrates: ``` 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) et 1 200 obus de 30 mm (600 kg, 0,5 kg par obus emballage compris : une estimation). -Chaque lot par défaut tient en 6 ou 7 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. +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**. diff --git a/docs/mission-maker/fat-cow.md b/docs/mission-maker/fat-cow.md index ec4bf2a2..99409f3d 100644 --- a/docs/mission-maker/fat-cow.md +++ b/docs/mission-maker/fat-cow.md @@ -39,8 +39,8 @@ All in the `fob` family of the configuration. |---|---|---| | `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, 20 laser Hellfires, 20 radar Hellfires, 1 200 rounds of 30 mm | The crates a player cow carries its stock in (see below). | -| `fatCowLoadouts` | four Hellfire and 30 mm 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). | +| `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). | @@ -63,12 +63,30 @@ fatCowLoadouts: ``` A player cow finds them under **Load**, in a logistic zone; a flight requesting an AI cow picks one under **Request a Fat Cow**. -The four defaults all carry two 800 gal tanks (4 800 kg), with 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. -They weigh 7 800 to 8 200 kg, 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 %. +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. -`weapons.missiles.AGM_114` (the AGM-114L, from the DCS data mine) and `weapons.shells.M230_HEDP M789` (the Apache's rounds) are not yet checked as warehouse items. +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 @@ -86,8 +104,8 @@ fatCowCrates: ``` 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) and 1 200 rounds of 30 mm (600 kg, 0.5 kg a round with its packing: an estimate). -Every default loadout fits in 6 or 7 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. +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**. diff --git a/docs/pilot/fat-cow.fr.md b/docs/pilot/fat-cow.fr.md index 587aa7dc..c09c48fe 100644 --- a/docs/pilot/fat-cow.fr.md +++ b/docs/pilot/fat-cow.fr.md @@ -15,9 +15,16 @@ Tout se trouve dans **F10 → CTLD → Fat Cow**. 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 au choix : 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. - Cela fait de 7 800 à 8 200 kg en 6 ou 7 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, 20 Hellfire radar ou 1 200 obus de 30 mm, puis chargez-la comme n'importe quelle caisse CTLD. + 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. @@ -54,7 +61,8 @@ Vous pouvez donc redécoller pour vous déplacer en stationnaire jusqu'à sa FAR **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 : choisissez ce dont votre patrouille a besoin. +**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" diff --git a/docs/pilot/fat-cow.md b/docs/pilot/fat-cow.md index ecf04094..bde4a9f2 100644 --- a/docs/pilot/fat-cow.md +++ b/docs/pilot/fat-cow.md @@ -15,9 +15,16 @@ Everything is in **F10 → CTLD → Fat Cow**. 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 one of: 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. - That is 7 800 to 8 200 kg in 6 or 7 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 Hellfires, 20 radar Hellfires or 1 200 rounds of 30 mm, then load it like any CTLD crate. + 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. @@ -54,7 +61,8 @@ So you can lift off to hover-taxi onto its FARP, or reposition, without the cow **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: pick what your flight needs. +**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" diff --git a/src/CTLD_config.yaml b/src/CTLD_config.yaml index baf18894..6e338730 100644 --- a/src/CTLD_config.yaml +++ b/src/CTLD_config.yaml @@ -294,6 +294,30 @@ mm_facing: 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: @@ -329,6 +353,70 @@ mm_facing: 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 diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index 1673dfd6..77e5d651 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -471,14 +471,14 @@ 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 Hellfires, 20 radar Hellfires, 1200 rounds of 30 mm.' - 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, 20 Hellfire radar, 1200 obus de 30 mm." + 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) and a list of items (DCS warehouse item name and count). Default: two 800 gal tanks (4800 kg) with 40 Hellfires (25 laser, 15 radar) and 2400 rounds, 60 Hellfires (38 laser, 22 radar) and 1200 rounds, 40 laser Hellfires and 2400 rounds, or 60 laser Hellfires and 1200 rounds: 7800 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) et une liste d'items (nom d'article d'entrepôt DCS et nombre). Par défaut : deux réservoirs de 800 gal (4800 kg) avec 40 Hellfire (25 laser, 15 radar) et 2400 obus, 60 Hellfire (38 laser, 22 radar) et 1200 obus, 40 Hellfire laser et 2400 obus, ou 60 Hellfire laser et 1200 obus : de 7800 à 8200 kg, sous ce qu'emporte un CH-47F avec 80 % de son carburant interne." + 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} diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index d6aeb104..3c838c25 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -123,6 +123,12 @@ 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 @@ -737,6 +743,34 @@ 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 } @@ -745,11 +779,7 @@ function CTLDFatCowManager:buildMenuSection(playerObj, menu) 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 }) - for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do - menu:addCommand(loadPath, ctld.tr(l.desc or "?"), - function(arg) mgr():loadLoadout(arg.unitName, arg.index) end, - { unitName = playerObj.unitName, index = i }, { order = i }) - end + 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, @@ -1184,11 +1214,7 @@ function CTLDFatCowManager:_buildAIMenu(playerObj, menu, path) local u = { unitName = playerObj.unitName } local requestPath = { path[1], path[2], ctld.tr("Request a Fat Cow") } menu:addSubMenu(path, requestPath[3], { order = 1 }) - for i, l in ipairs(ctld.gs("fatCowLoadouts") or {}) do - menu:addCommand(requestPath, ctld.tr(l.desc or "?"), - function(arg) mgr():requestAICow(arg.unitName, arg.index) end, - { unitName = playerObj.unitName, index = i }, { order = i }) - end + 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 }) diff --git a/src/CTLD_i18n_en.lua b/src/CTLD_i18n_en.lua index ec923f26..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.33" +ctld.i18n["en"].translation_version = "1.34" --- groups names ctld.i18n["en"]["Standard Group"] = "Standard Group" @@ -686,3 +686,25 @@ 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 85a92292..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.33" +ctld.i18n["es"].translation_version = "1.34" --- groups names ctld.i18n["es"]["Standard Group"] = "Grupo estándar" @@ -693,3 +693,25 @@ 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 e87ce3bd..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.33" +ctld.i18n["fr"].translation_version = "1.34" --- groups names ctld.i18n["fr"]["Standard Group"] = "Groupe standard" @@ -718,3 +718,25 @@ 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 4f12d1f8..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.33" +ctld.i18n["ko"].translation_version = "1.34" --- groups names ctld.i18n["ko"]["Standard Group"] = "표준 그룹" @@ -575,3 +575,25 @@ 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/tests/ci/data/config_defaults.json b/tests/ci/data/config_defaults.json index 684a7962..7243ad05 100644 --- a/tests/ci/data/config_defaults.json +++ b/tests/ci/data/config_defaults.json @@ -375,6 +375,42 @@ "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, @@ -443,6 +479,128 @@ "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": [ diff --git a/tests/ci/unit/fatcow_piloted_spec.lua b/tests/ci/unit/fatcow_piloted_spec.lua index 0f5ce3e7..382c6332 100644 --- a/tests/ci/unit/fatcow_piloted_spec.lua +++ b/tests/ci/unit/fatcow_piloted_spec.lua @@ -262,6 +262,27 @@ describe("Fat Cow loadouts", function() 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 } }, From 3b118993ce5c169b9d5baf13e772015b7d6314c5 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Tue, 27 Oct 2026 09:54:58 +0100 Subject: [PATCH 36/36] fix(fat-cow): stow the cow when its depot fails, close a FARP left by a pilot Review of PR #273: - the fuel depot step is no longer `critical`: a depot that cannot be added stows the cow and tells its audience, instead of leaving it deploying in silence; onSceneFailed empties the FARP already added - a pilot taking the slot of a piloted cow whose FARP was left open closes it, emptied, instead of "already deployed" and a pack that no longer finds the previous pilot's crates - comments: what writeWarehouse can reach, deploy's "failed" reason, the module's dependencies; pilot docs and CHANGELOG Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/pilot/fat-cow.fr.md | 2 +- docs/pilot/fat-cow.md | 2 +- src/CTLD_fatCow.lua | 20 ++++++++++++---- src/scenes/CTLD_fatCowScene.lua | 11 +++++++-- tests/ci/unit/fatcow_core_spec.lua | 37 ++++++++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ee704c..2ee4c9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ED announced a native Fat Cow on 2024-04-19, and again on 2024-08-23; DCS 2.9.30 - **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. diff --git a/docs/pilot/fat-cow.fr.md b/docs/pilot/fat-cow.fr.md index c09c48fe..bfd38f7c 100644 --- a/docs/pilot/fat-cow.fr.md +++ b/docs/pilot/fat-cow.fr.md @@ -36,7 +36,7 @@ Tout se trouve dans **F10 → CTLD → Fat Cow**. 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. +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 ». diff --git a/docs/pilot/fat-cow.md b/docs/pilot/fat-cow.md index bde4a9f2..9d1106e6 100644 --- a/docs/pilot/fat-cow.md +++ b/docs/pilot/fat-cow.md @@ -36,7 +36,7 @@ Everything is in **F10 → CTLD → Fat Cow**. 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. +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". diff --git a/src/CTLD_fatCow.lua b/src/CTLD_fatCow.lua index 3c838c25..5c306f6f 100644 --- a/src/CTLD_fatCow.lua +++ b/src/CTLD_fatCow.lua @@ -9,7 +9,8 @@ -- 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, CTLDUtils +-- 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. @@ -58,7 +59,8 @@ local function clearWeapons(w) end --- Writes the stock into a FARP warehouse: jet fuel, every other liquid at 0, each stocked item, and --- nothing else (a weapon someone else put there is removed). +-- 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) @@ -265,7 +267,8 @@ 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" +-- @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 @@ -305,11 +308,14 @@ function CTLDFatCowManager:onSceneLaidOut(scene) self:_notify(cow, ctld.tr("Fat Cow FARP open: %1", CTLDFatCow.describeStock(cow.stock))) end ---- Called by the scene when its FARP could not be added: the cow is stowed again. +--- 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) @@ -362,6 +368,12 @@ function CTLDFatCowManager:onBirth(event) 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) diff --git a/src/scenes/CTLD_fatCowScene.lua b/src/scenes/CTLD_fatCowScene.lua index e136e240..218046f0 100644 --- a/src/scenes/CTLD_fatCowScene.lua +++ b/src/scenes/CTLD_fatCowScene.lua @@ -49,14 +49,21 @@ fatCowScene.steps = { end, }, - -- Step 2: the fuel depot behind the ramp — the only supplier the FARP needs. + -- 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", - critical = true, + 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 diff --git a/tests/ci/unit/fatcow_core_spec.lua b/tests/ci/unit/fatcow_core_spec.lua index 6f177e78..5424b3f9 100644 --- a/tests/ci/unit/fatcow_core_spec.lua +++ b/tests/ci/unit/fatcow_core_spec.lua @@ -174,6 +174,26 @@ describe("Fat Cow FARP scene", function() 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 @@ -384,6 +404,23 @@ describe("CTLDFatCowManager deploy / pack", function() 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 = {} })