diff --git a/Voice Attack/CTLD_TEST.vap b/Voice Attack/CTLD_TEST.vap deleted file mode 100644 index e4d11eb..0000000 --- a/Voice Attack/CTLD_TEST.vap +++ /dev/null @@ -1,25 +0,0 @@ - - - DCS CTLD Menu Navigation - a8f7c9d2-4e6b-4c1a-9f3e-2a8d7c6b5e4f - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c0d - load troops - - - {RALT}\{F2}{F1}{F1} - - - - - 4b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e - show status;ctld status;check status - - - {RALT}\{F5}{F2} - - - - - diff --git a/Voice Attack/CTLD_VoiceAttack_Guide.md b/Voice Attack/CTLD_VoiceAttack_Guide.md deleted file mode 100644 index eedbd9c..0000000 --- a/Voice Attack/CTLD_VoiceAttack_Guide.md +++ /dev/null @@ -1,266 +0,0 @@ -# DCS CTLD Voice Attack Profile Guide - -## Overview -This Voice Attack profile provides voice command navigation for the Moose CTLD (Combat Troop and Logistics Deployment) menu system in DCS World. All commands use direct navigation - saying the command automatically navigates through the entire F10 menu path. - -## Installation - -### 1. Import the Profile -1. Open Voice Attack -2. Click the wrench icon (Edit Profile) -3. Click "Import Profile" -4. Navigate to `CTLD_VoiceAttack_Profile.xml` -5. Select the profile and click OK - -### 2. DCS Key Binding (No Configuration Needed!) -The profile uses **Right Alt + Backslash** (`RAlt + \`) which works in all situations: - -- **On the ground:** Backslash `\` alone opens F10, but `RAlt + \` also works -- **In the air:** Only `RAlt + \` works (backslash alone is disabled) - -The profile uses `RAlt + \` for all commands, ensuring compatibility both on ground and in air. - -**No DCS configuration changes needed** - this is the default binding! - -## F10 Key Binding - -### How DCS F10 Menu Works -- **On ground:** Backslash `\` key opens F10 menu -- **In the air:** `\` is disabled, must use `Right Alt + \` - -### Voice Attack Profile -All commands use **`RAlt + \`** which works in both situations: -``` -"load troops" → RAlt + \ + F2 + F1 + F1 -"build here" → RAlt + \ + F2 + F2 + F1 -"vectors to pickup" → RAlt + \ + F4 + F2 -``` - -This ensures voice commands work whether you're on the ground or flying! - -## Command Reference - -### OPERATIONS: Troop Transport - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "load troops" | Load troops at supply zone | Operations → Troop Transport → Load Troops | -| "deploy troops"
"deploy hold"
"deploy defend" | Deploy troops in defensive posture | Operations → Troop Transport → Deploy [Hold Position] | -| "deploy attack"
"troops attack" | Deploy troops with attack orders | Operations → Troop Transport → Deploy [Attack] | - -### OPERATIONS: Build - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "build here"
"build at position" | Build collected crates at location | Operations → Build → Build Here | -| "refresh buildable list"
"refresh build list" | Update list of buildable items | Operations → Build → Refresh Buildable List | - -### OPERATIONS: MEDEVAC (if enabled) - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "list medevac"
"active medevac requests" | Show all active MEDEVAC missions | Operations → MEDEVAC → List Active MEDEVAC Requests | -| "nearest medevac location"
"medevac location" | Show nearest MEDEVAC crew location | Operations → MEDEVAC → Nearest MEDEVAC Location | -| "salvage points"
"check salvage" | Display coalition salvage points | Operations → MEDEVAC → Coalition Salvage Points | -| "vectors to medevac"
"medevac vectors" | Get bearing/range to nearest crew | Operations → MEDEVAC → Vectors to Nearest MEDEVAC | -| "mash locations"
"show mash" | List all MASH zones | Operations → MEDEVAC → MASH Locations | -| "smoke crew locations"
"mark crews" | Pop smoke at MEDEVAC crew positions | Operations → MEDEVAC → Pop Smoke at Crew Locations | -| "smoke mash zones"
"mark mash" | Pop smoke at MASH delivery zones | Operations → MEDEVAC → Pop Smoke at MASH Zones | - -### LOGISTICS: Crate Management - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "drop one crate"
"drop crate" | Drop single loaded crate | Logistics → Crate Management → Drop One Loaded Crate | -| "drop all crates"
"drop cargo" | Drop all loaded crates | Logistics → Crate Management → Drop All Loaded Crates | -| "mark nearest crate"
"smoke crate"
"remark crate" | Re-mark closest crate with smoke | Logistics → Crate Management → Re-mark Nearest Crate | -| "show inventory"
"check inventory"
"zone inventory" | Display zone inventory | Logistics → Show Inventory at Nearest Zone | - -### FIELD TOOLS - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "create drop zone"
"mark drop zone" | Create new drop zone at position | Field Tools → Create Drop Zone (AO) | -| "smoke green"
"green smoke" | Pop green smoke at location | Field Tools → Smoke My Location → Green | -| "smoke red"
"red smoke" | Pop red smoke at location | Field Tools → Smoke My Location → Red | -| "smoke white"
"white smoke" | Pop white smoke at location | Field Tools → Smoke My Location → White | -| "smoke orange"
"orange smoke" | Pop orange smoke at location | Field Tools → Smoke My Location → Orange | -| "smoke blue"
"blue smoke" | Pop blue smoke at location | Field Tools → Smoke My Location → Blue | - -### NAVIGATION - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "vectors to crate"
"find crate"
"nearest crate" | Get bearing/range to nearest crate | Navigation → Request Vectors to Nearest Crate | -| "vectors to pickup"
"find pickup zone"
"nearest pickup" | Get bearing/range to pickup zone | Navigation → Vectors to Nearest Pickup Zone | -| "smoke nearest zone"
"mark nearest zone" | Smoke closest zone (any type) | Navigation → Smoke Nearest Zone | -| "smoke all zones"
"mark all zones"
"smoke nearby zones" | Smoke all zones within 5km | Navigation → Smoke All Nearby Zones (5km) | -| "vectors to mash"
"find mash"
"nearest mash" | Get bearing/range to MASH | Navigation → Vectors to Nearest MASH | -| "enable hover coach"
"hover coach on" | Enable hover pickup guidance | Navigation → Hover Coach: Enable | -| "disable hover coach"
"hover coach off" | Disable hover pickup guidance | Navigation → Hover Coach: Disable | - -### STATUS & ADMIN - -| Voice Command | Action | Menu Path | -|--------------|--------|-----------| -| "show status"
"ctld status"
"check status" | Display CTLD system status | Admin/Help → Show CTLD Status | -| "draw zones on map"
"show zones"
"mark zones on map" | Draw all zones on F10 map | Admin/Help → Draw CTLD Zones on Map | -| "clear map drawings"
"clear map marks"
"remove zone marks" | Remove zone drawings from map | Admin/Help → Clear CTLD Map Drawings | -| "medevac statistics"
"medevac stats"
"show medevac stats" | Display MEDEVAC statistics | Admin/Help → Show MEDEVAC Statistics | - -### QUICK ACCESS COMMANDS - -These are alternate phrases for commonly used functions: - -| Voice Command | Equivalent To | -|--------------|---------------| -| "quick pickup"
"pickup mode" | "load troops" | -| "quick deploy"
"fast deploy" | "deploy troops" | -| "quick build"
"fast build" | "build here" | - -## Usage Tips - -### 1. Voice Recognition Accuracy -- Speak clearly and at normal volume -- Pause briefly between words for best recognition -- If a command doesn't work, try an alternate phrase -- Train Voice Attack with your voice for better accuracy - -### 2. Menu Navigation Timing -- Commands execute instantly with no delays -- DCS menu system is fast - no need to wait between commands -- If you're in a different menu, the command will navigate from wherever you are - -### 3. Common Workflows - -**Troop Transport Mission:** -1. "vectors to pickup" (find supply zone) -2. "load troops" (board passengers) -3. Fly to destination -4. "deploy hold" or "deploy attack" (unload with orders) - -**Crate Logistics Mission:** -1. "vectors to pickup" (find supply zone) -2. Use manual F10 menu to request specific crates -3. "vectors to crate" (find spawned crate) -4. Pick up crate -5. "build here" (deploy at destination) - -**MEDEVAC Mission:** -1. "list medevac" (check active requests) -2. "vectors to medevac" (find nearest crew) -3. Pick up crew (auto-load when landed nearby) -4. "vectors to mash" (find hospital) -5. Deploy at MASH (auto-unload) -6. "salvage points" (check rewards) - -**Reconnaissance:** -1. "smoke all zones" (mark nearby objectives) -2. "draw zones on map" (see all zones on F10) -3. "vectors to pickup" (return to base) -4. "clear map drawings" (clean up map) - -### 4. Combat Situations -Voice commands work best when: -- ✓ Flying stable (not in combat maneuvers) -- ✓ Hands free for flight controls -- ✓ Clear of radio chatter/background noise -- ✗ Not recommended during combat or emergency procedures - -### 5. Request Crate Limitation -**Note:** The profile does NOT include voice commands for requesting specific crate types because: -- There are dozens of crate types (vehicles, SAMs, FOBs, etc.) -- Categories vary by mission configuration -- Manual F10 navigation (F2 → Logistics → Request Crate) is more practical - -Use voice commands for navigation/status, manual F10 for crate requests. - -## Troubleshooting - -### Command Not Recognized -1. Check Voice Attack is running and profile is active -2. Train Voice Attack with your voice (Tools → Train Profile) -3. Try alternate phrases for the command -4. Verify microphone input levels - -### Wrong Menu Opens -1. Verify DCS is using default key bindings (RAlt + \ for F10) -2. Check that backslash key isn't rebound in DCS controls -3. Try manual RAlt + \ to verify F10 menu opens - -### Command Works But Menu Doesn't Navigate -1. Verify DCS is the active window -2. Check F10 menu is not already open -3. Ensure no other key bindings conflict with F-keys -4. Try manual navigation to verify menu structure matches profile - -### MEDEVAC Commands Not Available -- These commands only work if MEDEVAC system is enabled in the mission -- Check mission briefing or use "show status" to verify MEDEVAC is active - -## Profile Customization - -### Adding New Commands -1. Open Voice Attack profile editor -2. Click "New Command" -3. Set "When I say" to your phrase -4. Add Action: "Send keys to active window" -5. Enter key sequence (e.g., `{RALT}{F2}{F1}{F1}`) -6. Save command - -### Modifying Existing Commands -1. Find command in list -2. Click "Edit" -3. Modify "When I say" phrases -4. Update key sequence if menu structure changed -5. Save changes - -### Key Sequence Format -- `{RALT}` = Right Alt key -- `\\` = Backslash key (escaped in XML) -- `{F1}` through `{F9}` = Function keys -- Example: `{RALT}\\{F2}{F3}{F1}` = RAlt, \, F2, F3, F1 in sequence - -## Menu Structure Reference - -``` -CTLD (Root - F2) -├── F1: Operations -│ ├── F1: Troop Transport -│ ├── F2: Build -│ └── F3: MEDEVAC -├── F2: Logistics -│ ├── F1: Request Crate -│ ├── F2: Recipe Info -│ ├── F3: Crate Management -│ └── F4: Show Inventory -├── F3: Field Tools -├── F4: Navigation -└── F5: Admin/Help -``` - -See full menu tree diagram in main documentation. - -## Version Information - -- **Profile Version:** 1.0 -- **CTLD Version:** 0.1.0-alpha (Moose_CTLD_Pure) -- **Voice Attack Version:** 1.8+ (tested on 1.10) -- **DCS Version:** Compatible with current stable/open beta - -## Support & Updates - -If the menu structure changes in future CTLD updates: -1. Check `Moose_CTLD.lua` function `BuildGroupMenus()` (around line 2616) -2. Update key sequences in Voice Attack profile -3. Test each command to verify navigation - -## Credits - -- **CTLD System:** Moose_CTLD_Pure custom implementation -- **Voice Attack:** VoiceAttack by VoiceAttack.com -- **DCS World:** Eagle Dynamics - ---- - -**Happy Flying! 🚁** diff --git a/Voice Attack/CTLD_VoiceAttack_Profile.vap b/Voice Attack/CTLD_VoiceAttack_Profile.vap deleted file mode 100644 index ccc61c8..0000000 --- a/Voice Attack/CTLD_VoiceAttack_Profile.vap +++ /dev/null @@ -1,1138 +0,0 @@ - - - DCS CTLD Menu Navigation - a8f7c9d2-4e6b-4c1a-9f3e-2a8d7c6b5e4f - - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c0d - load troops - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F1 - - - 300 - - - F1 - - - - - 4b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e - deploy troops;deploy hold;deploy defend - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F1 - - - 300 - - - F2 - - - - - 5c9d0e1f-2a3b-4c5d-6e7f-8a9b0c1d2e3f - deploy attack;troops attack - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F1 - - - 300 - - - F3 - - - - - - - 6d0e1f2a-3b4c-5d6e-7f8a-9b0c1d2e3f4a - build here;build at position - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F2 - - - 300 - - - F1 - - - - - 7e1f2a3b-4c5d-6e7f-8a9b-0c1d2e3f4a5b - refresh buildable list;refresh build list - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F2 - - - 300 - - - F4 - - - - - - - 8f2a3b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c - list medevac;active medevac requests - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F1 - - - - - 9a3b4c5d-6e7f-8a9b-0c1d-2e3f4a5b6c7d - nearest medevac location;medevac location - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F2 - - - - - 0b4c5d6e-7f8a-9b0c-1d2e-3f4a5b6c7d8e - salvage points;check salvage - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F3 - - - - - 1c5d6e7f-8a9b-0c1d-2e3f-4a5b6c7d8e9f - vectors to medevac;medevac vectors - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F4 - - - - - 2d6e7f8a-9b0c-1d2e-3f4a-5b6c7d8e9f0a - mash locations;show mash - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F5 - - - - - 3e7f8a9b-0c1d-2e3f-4a5b-6c7d8e9f0a1b - smoke crew locations;mark crews - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F6 - - - - - 4f8a9b0c-1d2e-3f4a-5b6c-7d8e9f0a1b2c - smoke mash zones;mark mash - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F3 - - - 300 - - - F7 - - - - - - - 5a9b0c1d-2e3f-4a5b-6c7d-8e9f0a1b2c3d - drop one crate;drop crate - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F4 - - - 300 - - - F1 - - - - - 6b0c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e - drop all crates;drop cargo - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F4 - - - 300 - - - F2 - - - - - 7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f - mark nearest crate;smoke crate;remark crate - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F4 - - - 300 - - - F3 - - - - - 8d2e3f4a-5b6c-7d8e-9f0a-1b2c3d4e5f6a - show inventory;check inventory;zone inventory - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F4 - - - 300 - - - F4 - - - - - - - 9e3f4a5b-6c7d-8e9f-0a1b-2c3d4e5f6a7b - create drop zone;mark drop zone - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F3 - - - 300 - - - F1 - - - - - 0f4a5b6c-7d8e-9f0a-1b2c-3d4e5f6a7b8c - smoke green;green smoke - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F3 - - - 300 - - - F2 - - - 300 - - - F1 - - - - - 1a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d - smoke red;red smoke - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F3 - - - 300 - - - F2 - - - 300 - - - F2 - - - - - 2b6c7d8e-9f0a-1b2c-3d4e-5f6a7b8c9d0e - smoke white;white smoke - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F3 - - - 300 - - - F2 - - - 300 - - - F3 - - - - - 3c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f - smoke orange;orange smoke - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F3 - - - 300 - - - F2 - - - 300 - - - F4 - - - - - 4d8e9f0a-1b2c-3d4e-5f6a-7b8c9d0e1f2a - smoke blue;blue smoke - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F3 - - - 300 - - - F2 - - - 300 - - - F5 - - - - - - - 5e9f0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b - vectors to crate;find crate;nearest crate - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F1 - - - - - 6f0a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c - vectors to pickup;find pickup zone;nearest pickup - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F2 - - - - - 7a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d - smoke nearest zone;mark nearest zone - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F3 - - - - - 8b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e - smoke all zones;mark all zones;smoke nearby zones - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F4 - - - - - 9c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f - vectors to mash;find mash;nearest mash - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F6 - - - - - 0d4e5f6a-7b8c-9d0e-1f2a-3b4c5d6e7f8a - enable hover coach;hover coach on - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F7 - - - - - 1e5f6a7b-8c9d-0e1f-2a3b-4c5d6e7f8a9b - disable hover coach;hover coach off - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F4 - - - 300 - - - F8 - - - - - - - 2f6a7b8c-9d0e-1f2a-3b4c-5d6e7f8a9b0c - show status;ctld status;check status - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F5 - - - 300 - - - F2 - - - - - 3a7b8c9d-0e1f-2a3b-4c5d-6e7f8a9b0c1d - draw zones on map;show zones;mark zones on map - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F5 - - - 300 - - - F3 - - - - - 4b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d3e - clear map drawings;clear map marks;remove zone marks - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F5 - - - 300 - - - F4 - - - - - 5c9d0e1f-2a3b-4c5d-6e7f-8a9b0c1d2e4f - medevac statistics;medevac stats;show medevac stats - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F5 - - - 300 - - - F5 - - - - - - - a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - quick pickup;pickup mode - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F1 - - - 300 - - - F1 - - - - - b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e - quick deploy;fast deploy - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F1 - - - 300 - - - F3 - - - - - c3d4e5f6-7a8b-9c0d-1e2f-3a4b5c6d7e8f - quick build;fast build - - - RALT - - - 200 - - - BACKSLASH - - - 300 - - - F2 - - - 300 - - - F2 - - - 300 - - - F1 - - - - - diff --git a/Voice Attack/CTLD_VoiceAttack_Profile_F10.vap b/Voice Attack/CTLD_VoiceAttack_Profile_F10.vap deleted file mode 100644 index 9cd3339..0000000 --- a/Voice Attack/CTLD_VoiceAttack_Profile_F10.vap +++ /dev/null @@ -1,1497 +0,0 @@ - - - CTLD Voice Menu (F10) - f2c64b1e-0e5a-4cae-9f4d-0b1c2d3e4f5a - - - - c101a001-7e5f-4d2c-8b3a-1c2d3e4f5a60 - load troops;load infantry - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F1 - - - - - c101a002-7e5f-4d2c-8b3a-1c2d3e4f5a61 - load assault squad;load assault team - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F1 - - - - - c101a003-7e5f-4d2c-8b3a-1c2d3e4f5a62 - load manpads team;load aa squad - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F2 - - - - - c101a004-7e5f-4d2c-8b3a-1c2d3e4f5a63 - load anti tank team;load at squad - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F3 - - - - - c101a005-7e5f-4d2c-8b3a-1c2d3e4f5a64 - load mortar team;load mortar squad - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F4 - - - - - c101a006-7e5f-4d2c-8b3a-1c2d3e4f5a65 - deploy troops hold;deploy hold - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F3 - - - - - c101a007-7e5f-4d2c-8b3a-1c2d3e4f5a66 - deploy troops attack;deploy attack - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F1 - - - 250 - - - F4 - - - - - - - c101a008-7e5f-4d2c-8b3a-1c2d3e4f5a67 - build here;build at position - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F1 - - - - - c101a009-7e5f-4d2c-8b3a-1c2d3e4f5a68 - open build advanced;build advanced menu - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F2 - - - - - c101a00a-7e5f-4d2c-8b3a-1c2d3e4f5a69 - open buildable near you;show buildable list - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F3 - - - - - c101a00b-7e5f-4d2c-8b3a-1c2d3e4f5a6a - refresh build list;refresh buildable list - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F2 - - - 250 - - - F4 - - - - - - - c101a00c-7e5f-4d2c-8b3a-1c2d3e4f5a6b - list medevac requests;active medevac - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F1 - - - - - c101a00d-7e5f-4d2c-8b3a-1c2d3e4f5a6c - nearest medevac location;medevac location - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F2 - - - - - c101a00e-7e5f-4d2c-8b3a-1c2d3e4f5a6d - check salvage points;show salvage - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F3 - - - - - c101a00f-7e5f-4d2c-8b3a-1c2d3e4f5a6e - vectors to medevac;medevac vectors - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F4 - - - - - c101a010-7e5f-4d2c-8b3a-1c2d3e4f5a6f - show mash locations;list mash - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F5 - - - - - c101a011-7e5f-4d2c-8b3a-1c2d3e4f5a70 - smoke crew locations;mark crews - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F6 - - - - - c101a012-7e5f-4d2c-8b3a-1c2d3e4f5a71 - smoke mash zones;mark mash - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F7 - - - - - c101a013-7e5f-4d2c-8b3a-1c2d3e4f5a72 - medevac guide;salvage guide - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F1 - - - 250 - - - F3 - - - 250 - - - F8 - - - - - - - c101a014-7e5f-4d2c-8b3a-1c2d3e4f5a73 - open request crate;request crate menu - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F2 - - - 250 - - - F1 - - - 200 - - - - - c101a015-7e5f-4d2c-8b3a-1c2d3e4f5a74 - open recipe info;recipe info menu - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F2 - - - 250 - - - F2 - - - 200 - - - - - c101a016-7e5f-4d2c-8b3a-1c2d3e4f5a75 - drop one crate;drop crate - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F1 - - - - - c101a017-7e5f-4d2c-8b3a-1c2d3e4f5a76 - drop all crates;drop cargo - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F2 - - - - - c101a018-7e5f-4d2c-8b3a-1c2d3e4f5a77 - remark nearest crate;smoke crate - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F3 - - - - - c101a019-7e5f-4d2c-8b3a-1c2d3e4f5a78 - show zone inventory;check inventory - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F2 - - - 250 - - - F4 - - - - - - - c101a01a-7e5f-4d2c-8b3a-1c2d3e4f5a79 - create drop zone;mark drop zone - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F1 - - - - - c101a01b-7e5f-4d2c-8b3a-1c2d3e4f5a7a - smoke green;green smoke - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F2 - - - 250 - - - F1 - - - - - c101a01c-7e5f-4d2c-8b3a-1c2d3e4f5a7b - smoke red;red smoke - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F2 - - - 250 - - - F2 - - - - - c101a01d-7e5f-4d2c-8b3a-1c2d3e4f5a7c - smoke white;white smoke - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F2 - - - 250 - - - F3 - - - - - c101a01e-7e5f-4d2c-8b3a-1c2d3e4f5a7d - smoke orange;orange smoke - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F2 - - - 250 - - - F4 - - - - - c101a01f-7e5f-4d2c-8b3a-1c2d3e4f5a7e - smoke blue;blue smoke - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F3 - - - 250 - - - F2 - - - 250 - - - F5 - - - - - - - c101a020-7e5f-4d2c-8b3a-1c2d3e4f5a7f - vectors to crate;find crate - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F1 - - - - - c101a021-7e5f-4d2c-8b3a-1c2d3e4f5a80 - vectors to pickup zone;find pickup zone - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F2 - - - - - c101a022-7e5f-4d2c-8b3a-1c2d3e4f5a81 - smoke nearest zone;mark nearest zone - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F3 - - - - - c101a023-7e5f-4d2c-8b3a-1c2d3e4f5a82 - smoke all zones;mark all zones - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F4 - - - - - c101a024-7e5f-4d2c-8b3a-1c2d3e4f5a83 - vectors to medevac crew;find medevac crew - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F5 - - - - - c101a025-7e5f-4d2c-8b3a-1c2d3e4f5a84 - vectors to mash;find mash - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F6 - - - - - c101a026-7e5f-4d2c-8b3a-1c2d3e4f5a85 - enable hover coach;hover coach on - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F7 - - - - - c101a027-7e5f-4d2c-8b3a-1c2d3e4f5a86 - disable hover coach;hover coach off - - - RALT+BACKSLASH - - - 200 - - - F10 - - - 250 - - - F2 - - - 250 - - - F4 - - - 250 - - - F8 - - - - - diff --git a/Voice Attack/CTLD_VoiceAttack_Profile_F10_commands.html b/Voice Attack/CTLD_VoiceAttack_Profile_F10_commands.html deleted file mode 100644 index cb0ead3..0000000 --- a/Voice Attack/CTLD_VoiceAttack_Profile_F10_commands.html +++ /dev/null @@ -1,587 +0,0 @@ - - - - - CTLD VoiceAttack Profile — Command Reference - - - -
-
-

CTLD VoiceAttack Command Reference

-

All phrases below assume the VoiceAttack profile sends Right Alt + Backslash to open the radio menu, then F10 > F2 to enter the CTLD root. Speak any phrase shown to run the navigation sequence automatically.

-
- -
- Pro Tip: Fine-tune the pause durations inside VoiceAttack if your DCS instance needs more/less time for menus to populate. You can also add your own synonyms by editing the command string list for any entry. -
- -

Operations — Troop Transport

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhrasesActionMenu Path
- load troops - load infantry - Load default troop package.Operations → Troop Transport → Load Troops
- load assault squad - load assault team - Load Assault Squad preset (AS).Operations → Troop Transport → Load Troops (Type) → Assault Squad
- load manpads team - load aa squad - Load MANPADS Team preset (AA).Operations → Troop Transport → Load Troops (Type) → MANPADS Team
- load anti tank team - load at squad - Load Anti-Tank Team preset (AT).Operations → Troop Transport → Load Troops (Type) → AT Team
- load mortar team - load mortar squad - Load Mortar Team preset (AR).Operations → Troop Transport → Load Troops (Type) → Mortar Team
- deploy troops hold - deploy hold - Unload troops with Hold/Defend behavior.Operations → Troop Transport → Deploy [Hold Position]
- deploy troops attack - deploy attack - Unload troops with Attack/Advance behavior.Operations → Troop Transport → Deploy [Attack]
- -

Operations — Build

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhrasesActionMenu Path
- build here - build at position - Build using nearby crates (with confirm/cooldown rules).Operations → Build → Build Here
- open build advanced - build advanced menu - Open the dynamic build menu.Operations → Build → Build (Advanced)
- open buildable near you - show buildable list - Focus the “Buildable Near You” submenu.Operations → Build → Build (Advanced) → Buildable Near You
- refresh build list - refresh buildable list - Rescan crate availability for builds.Operations → Build → Refresh Buildable List
- -

Operations — MEDEVAC

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhrasesActionMenu Path
- list medevac requests - active medevac - Show all active MEDEVAC crews.Operations → MEDEVAC → List Active MEDEVAC Requests
- nearest medevac location - medevac location - Display nearest crew position.Operations → MEDEVAC → Nearest MEDEVAC Location
- check salvage points - show salvage - Report coalition salvage totals.Operations → MEDEVAC → Coalition Salvage Points
- vectors to medevac - medevac vectors - Provide bearing/range to nearest crew.Operations → MEDEVAC → Vectors to Nearest MEDEVAC
- show mash locations - list mash - List all MASH delivery zones.Operations → MEDEVAC → MASH Locations
- smoke crew locations - mark crews - Drop smoke on every active crew.Operations → MEDEVAC → Pop Smoke at Crew Locations
- smoke mash zones - mark mash - Drop smoke on all MASH zones.Operations → MEDEVAC → Pop Smoke at MASH Zones
- medevac guide - salvage guide - Show in-game MEDEVAC/salvage quick reference.Operations → MEDEVAC → MASH & Salvage System – Guide
- -

Logistics

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhrasesActionMenu Path
- open request crate - request crate menu - Open the crate request submenu (choose item manually).Logistics → Request Crate
- open recipe info - recipe info menu - Open recipe info submenu.Logistics → Recipe Info
- drop one crate - drop crate - Drop a single loaded crate.Logistics → Crate Management → Drop One Loaded Crate
- drop all crates - drop cargo - Drop every loaded crate.Logistics → Crate Management → Drop All Loaded Crates
- remark nearest crate - smoke crate - Smoke the nearest friendly crate.Logistics → Crate Management → Re-mark Nearest Crate (Smoke)
- show zone inventory - check inventory - Show stock at nearest supply/FOB zone.Logistics → Show Inventory at Nearest Zone
- -

Field Tools

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhrasesActionMenu Path
- create drop zone - mark drop zone - Create a player drop zone (AO).Field Tools → Create Drop Zone (AO)
- smoke green - green smoke - Drop green smoke at your position.Field Tools → Smoke My Location → Green
- smoke red - red smoke - Drop red smoke at your position.Field Tools → Smoke My Location → Red
- smoke white - white smoke - Drop white smoke at your position.Field Tools → Smoke My Location → White
- smoke orange - orange smoke - Drop orange smoke at your position.Field Tools → Smoke My Location → Orange
- smoke blue - blue smoke - Drop blue smoke at your position.Field Tools → Smoke My Location → Blue
- -

Navigation & Hover Coach

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PhrasesActionMenu Path
- vectors to crate - find crate - Show bearing/range to nearest friendly crate.Navigation → Request Vectors to Nearest Crate
- vectors to pickup zone - find pickup zone - Show bearing/range to nearest supply zone.Navigation → Vectors to Nearest Pickup Zone
- smoke nearest zone - mark nearest zone - Smoke the closest zone (pickup/drop/FOB/MASH).Navigation → Smoke Nearest Zone (Pickup/Drop/FOB/MASH)
- smoke all zones - mark all zones - Smoke every zone within 5 km.Navigation → Smoke All Nearby Zones (5km)
- vectors to medevac crew - find medevac crew - Show bearing/range to the nearest crew (shortcut).Navigation → Vectors to Nearest MEDEVAC Crew
- vectors to mash - find mash - Show bearing/range to nearest MASH.Navigation → Vectors to Nearest MASH
- enable hover coach - hover coach on - Enable hover coach prompts for this group.Navigation → Hover Coach: Enable
- disable hover coach - hover coach off - Disable hover coach prompts for this group.Navigation → Hover Coach: Disable
- - -
- - diff --git a/Voice Attack/CTLD_VoiceAttack_Profile_Fixed.vap b/Voice Attack/CTLD_VoiceAttack_Profile_Fixed.vap deleted file mode 100644 index 113a59d..0000000 --- a/Voice Attack/CTLD_VoiceAttack_Profile_Fixed.vap +++ /dev/null @@ -1,74 +0,0 @@ - - - DCS CTLD Menu Navigation - a8f7c9d2-4e6b-4c1a-9f3e-2a8d7c6b5e4f - - - - load troops - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c0d - PressKey - 0 - - 165 - - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c0e - Pause - 200 - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c0f - PressKey - 0 - - 220 - - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c10 - Pause - 300 - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c11 - PressKey - 0 - - 113 - - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c12 - Pause - 300 - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c13 - PressKey - 0 - - 112 - - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c14 - Pause - 300 - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c15 - PressKey - 0 - - 112 - - - - 3a7f2b1e-8c4d-4f9a-b2e1-5d6c7a8b9c0d - - - diff --git a/Voice Attack/CTLD_VoiceAttack_QuickRef.md b/Voice Attack/CTLD_VoiceAttack_QuickRef.md deleted file mode 100644 index 22777f7..0000000 --- a/Voice Attack/CTLD_VoiceAttack_QuickRef.md +++ /dev/null @@ -1,174 +0,0 @@ -# CTLD Voice Attack - Quick Reference Card - -## Essential Commands (Most Used) - -### Troop Operations -``` -"load troops" → Load troops at supply zone -"deploy hold" → Deploy troops (defensive) -"deploy attack" → Deploy troops (offensive) -``` - -### Logistics -``` -"build here" → Build crates at position -"drop all crates" → Drop all loaded cargo -"show inventory" → Check zone stock -``` - -### Navigation -``` -"vectors to pickup" → Find supply zone -"vectors to crate" → Find dropped crate -"smoke nearest zone" → Mark closest zone -``` - -### MEDEVAC (if enabled) -``` -"list medevac" → Show active requests -"vectors to medevac" → Find crew -"vectors to mash" → Find hospital -"salvage points" → Check rewards -``` - -### Status -``` -"show status" → CTLD system status -"hover coach on/off" → Toggle pickup guidance -``` - ---- - -## Full Command List - -### OPERATIONS -| Command | Action | -|---------|--------| -| load troops | Board passengers | -| deploy hold / deploy defend | Unload defensive | -| deploy attack / troops attack | Unload offensive | -| build here / build at position | Build collected crates | -| refresh buildable list | Update build options | - -### MEDEVAC (if enabled) -| Command | Action | -|---------|--------| -| list medevac / active medevac requests | List all missions | -| nearest medevac location | Find closest crew | -| salvage points / check salvage | Show points | -| vectors to medevac / medevac vectors | Bearing to crew | -| mash locations / show mash | List hospitals | -| smoke crew locations / mark crews | Mark crews | -| smoke mash zones / mark mash | Mark hospitals | - -### LOGISTICS -| Command | Action | -|---------|--------| -| drop one crate / drop crate | Drop single crate | -| drop all crates / drop cargo | Drop all crates | -| mark nearest crate / smoke crate | Re-mark crate | -| show inventory / check inventory | Zone stock | - -### FIELD TOOLS -| Command | Action | -|---------|--------| -| create drop zone / mark drop zone | New drop zone | -| smoke green / green smoke | Green smoke | -| smoke red / red smoke | Red smoke | -| smoke white / white smoke | White smoke | -| smoke orange / orange smoke | Orange smoke | -| smoke blue / blue smoke | Blue smoke | - -### NAVIGATION -| Command | Action | -|---------|--------| -| vectors to crate / find crate | Find crate | -| vectors to pickup / find pickup zone | Find supply | -| smoke nearest zone / mark nearest zone | Mark nearest | -| smoke all zones / smoke nearby zones | Mark all <5km | -| vectors to mash / find mash | Find MASH | -| enable hover coach / hover coach on | Coach ON | -| disable hover coach / hover coach off | Coach OFF | - -### STATUS & ADMIN -| Command | Action | -|---------|--------| -| show status / ctld status | System status | -| draw zones on map / show zones | Draw zones | -| clear map drawings | Clear drawings | -| medevac statistics / medevac stats | MEDEVAC stats | - -### QUICK ACCESS -| Command | Same As | -|---------|---------| -| quick pickup / pickup mode | load troops | -| quick deploy / fast deploy | deploy hold | -| quick build / fast build | build here | - ---- - -## F10 Key Binding - -**DCS Default (No config needed):** -- On ground: `\` key opens F10 -- In air: `RAlt + \` required (backslash alone disabled) - -**Voice Attack Profile:** -- All commands use `RAlt + \` (works everywhere) -- Example: "load troops" → **RAlt \ F2 F1 F1** - ---- - -## Common Workflows - -### Troop Transport -1. "vectors to pickup" -2. "load troops" -3. Fly to LZ -4. "deploy hold" or "deploy attack" - -### Crate Delivery -1. "vectors to pickup" -2. Request crates (manual F10) -3. "vectors to crate" -4. Pick up & deliver -5. "build here" - -### MEDEVAC -1. "list medevac" -2. "vectors to medevac" -3. Land near crew (auto-load) -4. "vectors to mash" -5. Land at MASH (auto-unload) -6. "salvage points" - -### Zone Recon -1. "smoke all zones" (mark <5km) -2. "draw zones on map" (F10 view) -3. "vectors to pickup" (navigate) -4. "clear map drawings" (cleanup) - ---- - -## Tips -✓ Speak clearly at normal volume -✓ Use alternate phrases if not recognized -✓ Train Voice Attack with your voice -✓ Best when flying stable, not in combat -✗ Don't use for crate requests (too many types) - ---- - -## Troubleshooting -- **Not recognized:** Train profile, try alternate phrase -- **Wrong menu:** Check F10 key (RAlt vs slash) -- **Doesn't navigate:** DCS must be active window -- **MEDEVAC missing:** System not enabled in mission - ---- - -**Print this card for cockpit reference!** - -Profile: CTLD_VoiceAttack_Profile.xml -Guide: CTLD_VoiceAttack_Guide.md -CTLD Version: 0.1.0-alpha diff --git a/Voice Attack/README_VoiceAttack.md b/Voice Attack/README_VoiceAttack.md deleted file mode 100644 index bfa835c..0000000 --- a/Voice Attack/README_VoiceAttack.md +++ /dev/null @@ -1,283 +0,0 @@ -# CTLD Voice Attack Integration - -This directory contains a complete Voice Attack profile for hands-free navigation of the Moose CTLD (Combat Troop and Logistics Deployment) menu system in DCS World. - -## Files in This Package - -| File | Purpose | -|------|---------| -| **CTLD_VoiceAttack_Profile.xml** | Voice Attack profile (import this) | -| **CTLD_VoiceAttack_Guide.md** | Complete user guide with all commands | -| **CTLD_VoiceAttack_QuickRef.md** | Quick reference card (print for cockpit) | -| **CTLD_Menu_Structure.md** | Full F10 menu tree diagram | -| **Moose_CTLD.lua** | Source CTLD system (menu structure defined here) | - -## Quick Start - -### 1. Install -1. Open Voice Attack -2. Import `CTLD_VoiceAttack_Profile.xml` -3. Activate the profile - -### 2. F10 Key Binding (Already Configured!) - -**DCS Default Behavior:** -- On ground: `\` (backslash) opens F10 menu -- In air: `RAlt + \` required (backslash alone disabled) - -**Voice Attack Profile:** -- Uses `RAlt + \` for all commands -- Works both on ground and in air -- **No DCS configuration needed!** - -### 3. Test -In DCS, say: **"show status"** -- Should open: F10 → CTLD → Admin/Help → Show CTLD Status - -## Most Useful Commands - -``` -"load troops" → Load passengers -"deploy hold" → Unload defensive -"build here" → Build crates -"drop all crates" → Drop cargo -"vectors to pickup" → Find supply zone -"vectors to crate" → Find crate -"smoke nearest zone" → Mark zone -"show status" → System info -``` - -## Command Categories - -- **Troop Operations:** Load, deploy (defensive/offensive) -- **Build Operations:** Build here, refresh list -- **MEDEVAC:** List requests, vectors, MASH locations, salvage points -- **Logistics:** Drop crates, re-mark crate, check inventory -- **Navigation:** Vectors to zones/crates/MASH, smoke zones -- **Field Tools:** Create drop zone, smoke colors -- **Status:** Show status, draw zones, statistics - -See `CTLD_VoiceAttack_Guide.md` for complete command list. - -## Documentation - -### For New Users -Start here: **CTLD_VoiceAttack_Guide.md** -- Installation instructions -- All commands with examples -- Common workflows -- Troubleshooting - -### For Flying -Print this: **CTLD_VoiceAttack_QuickRef.md** -- One-page command list -- Essential workflows -- Tips for in-flight use - -### For Developers -Reference: **CTLD_Menu_Structure.md** -- Complete F10 menu tree -- Key path tables -- Menu behavior notes -- Source code references - -## How It Works - -### Direct Navigation -All commands use **direct navigation** - one voice command executes the entire menu path: - -``` -Say: "load troops" -Sends: RAlt + \ + F2 + F1 + F1 -Result: Operations → Troop Transport → Load Troops -``` - -No step-by-step navigation, no waiting between keys. - -### No TTS Feedback -Commands execute silently - DCS provides on-screen feedback. - -### All Non-Admin Functions Included -Profile includes: -- ✓ All operational commands -- ✓ All status/info commands -- ✓ Navigation and smoke -- ✓ MEDEVAC operations -- ✗ Specific crate requests (too many types - use manual F10) -- ✗ Debug/admin functions (intentionally excluded) - -## Voice Attack Tips - -### Improve Recognition -1. Train profile with your voice (Tools → Train Profile) -2. Speak clearly at normal volume -3. Use alternate phrases if not recognized -4. Reduce background noise - -### Best Practices -- ✓ Use during stable flight -- ✓ Keep hands free for controls -- ✓ Combine with manual F10 for crate requests -- ✗ Don't use during combat maneuvers -- ✗ Don't use during emergency procedures - -## Menu Structure Overview - -``` -CTLD (F2) -├── Operations (F1) -│ ├── Troop Transport -│ ├── Build -│ └── MEDEVAC -├── Logistics (F2) -│ ├── Request Crate -│ ├── Recipe Info -│ ├── Crate Management -│ └── Show Inventory -├── Field Tools (F3) -├── Navigation (F4) -└── Admin/Help (F5) -``` - -Full structure: `CTLD_Menu_Structure.md` - -## Limitations - -### Not Included -1. **Crate Requests:** Too many types (dozens of vehicles, SAMs, etc.) - - Use manual F10: CTLD → Logistics → Request Crate - -2. **Typed Troop Loading:** Submenu with 4+ troop types - - Use manual F10: CTLD → Operations → Troop Transport → Load Troops (Type) - -3. **Advanced Build Menu:** Dynamic list of buildable items - - Use manual F10: CTLD → Operations → Build → Build (Advanced) - -4. **Debug Commands:** Admin logging controls - - Not needed for normal operations - -### Why These Are Excluded -- **Crate requests:** Mission-specific, too many variations -- **Typed troops:** Rare use case, 4+ submenu items -- **Advanced build:** Dynamic content, better with manual selection -- **Debug:** Admin-only, not for regular flight ops - -Use voice commands for quick actions, manual F10 for detailed selections. - -## Compatibility - -- **CTLD Version:** 0.1.0-alpha (Moose_CTLD_Pure) -- **Voice Attack:** 1.8+ (tested on 1.10) -- **DCS World:** Current stable/open beta -- **Menu Type:** Per-player (MENU_GROUP) - -## Customization - -### Add New Commands -1. Open profile in Voice Attack -2. Study menu structure: `CTLD_Menu_Structure.md` -3. Create command with key sequence -4. Test in DCS - -### Update for Menu Changes -1. Check `Moose_CTLD.lua` function `BuildGroupMenus()` (~line 2616) -2. Update key sequences in Voice Attack -3. Update documentation files -4. Test all modified commands - -## Troubleshooting - -| Problem | Solution | -|---------|----------| -| Command not recognized | Train profile, try alternate phrase | -| Wrong menu opens | Verify DCS default binding (RAlt + \), check Controls settings | -| Menu doesn't navigate | DCS must be active window, F10 not already open | -| MEDEVAC missing | System not enabled in mission | -| Works on ground but not in air | Expected! Profile uses RAlt + \ (works in air) | - -Full troubleshooting: `CTLD_VoiceAttack_Guide.md` - -## Examples - -### Troop Transport Mission -``` -1. Say: "vectors to pickup" → Find supply zone -2. Fly to zone -3. Say: "load troops" → Board passengers -4. Fly to LZ -5. Say: "deploy hold" → Unload defensive -``` - -### Crate Logistics Mission -``` -1. Say: "vectors to pickup" → Find supply zone -2. Manual F10 to request crates → (Too many types for voice) -3. Say: "vectors to crate" → Find spawned crate -4. Pick up and fly to target -5. Say: "build here" → Deploy at destination -``` - -### MEDEVAC Mission -``` -1. Say: "list medevac" → Check active requests -2. Say: "vectors to medevac" → Find nearest crew -3. Land nearby (auto-load) -4. Say: "vectors to mash" → Find hospital -5. Land at MASH (auto-unload) -6. Say: "salvage points" → Check rewards -``` - -### Reconnaissance -``` -1. Say: "smoke all zones" → Mark nearby zones -2. Say: "draw zones on map" → See all zones on F10 -3. Manual F10 map navigation -4. Say: "clear map drawings" → Clean up -``` - -More workflows: `CTLD_VoiceAttack_Guide.md` - -## Support - -### If Menu Structure Changes -Menu structure is defined in `Moose_CTLD.lua`: -- Function: `CTLD:BuildGroupMenus(group)` -- Line: ~2616 - -Compare with `CTLD_Menu_Structure.md` to identify changes. - -### Profile Updates -1. Check code for menu changes -2. Update Voice Attack key sequences -3. Update documentation -4. Test all commands - -### Help Resources -- CTLD source code: `Moose_CTLD.lua` -- Menu diagram: `CTLD_Menu_Structure.md` -- Full guide: `CTLD_VoiceAttack_Guide.md` -- Quick ref: `CTLD_VoiceAttack_QuickRef.md` - -## Version History - -### Version 1.0 (Current) -- Initial release -- 40+ voice commands -- Direct navigation (no step-by-step) -- No TTS feedback -- Supports RAlt or slash key for F10 -- All non-admin functions included -- Comprehensive documentation - -## Credits - -- **CTLD System:** Moose_CTLD_Pure custom implementation -- **Voice Attack:** VoiceAttack by VoiceAttack.com -- **DCS World:** Eagle Dynamics -- **MOOSE Framework:** FlightControl-Master - ---- - -**Ready to fly hands-free? Import the profile and start with "show status"!** - -Questions? See `CTLD_VoiceAttack_Guide.md` for detailed help. diff --git a/temp_miz2/l10n/DEFAULT/Moose_CTLD.lua b/temp_miz2/l10n/DEFAULT/Moose_CTLD.lua deleted file mode 100644 index 7ea4de6..0000000 --- a/temp_miz2/l10n/DEFAULT/Moose_CTLD.lua +++ /dev/null @@ -1,14911 +0,0 @@ --- Pure-MOOSE, template-free CTLD-style logistics & troop transport --- Drop-in script: no MIST, no mission editor templates required --- Dependencies: Moose.lua must be loaded before this script --- Author: Copilot (generated) --- --- LOGGING SYSTEM: --- LogLevel configuration controls verbosity: 0=NONE, 1=ERROR, 2=INFO (default), 3=VERBOSE, 4=DEBUG --- Set LogLevel in config to reduce log spam on production servers. See LOGGING_GUIDE.md for details. - --- Contract --- Inputs: Config table or defaults. No ME templates needed. Zones may be named ME trigger zones or provided via coordinates in config. --- Outputs: F10 menus for helo/transport groups; crate spawning/building; troop load/unload; optional JTAC hookup (via FAC module); --- Error modes: missing Moose -> abort; unknown crate key -> message; spawn blocked in enemy airbase; zone missing -> message. --- --- Orignal Author of CTLD: Ciribob --- Moose adaptation: Lathe, Copilot, F99th-TracerFacer - --- #region Config -_DEBUG = true -local CTLD = {} -CTLD.Version = '1.0.2' -CTLD.__index = CTLD -CTLD._lastSalvageInterval = CTLD._lastSalvageInterval or 0 -CTLD._playerUnitPrefs = CTLD._playerUnitPrefs or {} - -local _msgGroup, _msgCoalition -local _log, _logError, _logInfo, _logVerbose, _logDebug, _logImmediate - --- General CTLD event messages (non-hover). Tweak freely. -CTLD.Messages = { - -- Crates - crate_spawn_requested = "Request received—spawning {type} crate at {zone}.", - pickup_zone_required = "Move within {zone_dist} {zone_dist_u} of a Supply Zone to request crates. Bearing {zone_brg}° to nearest zone.", - no_pickup_zones = "No Pickup Zones are configured for this coalition. Ask the mission maker to add supply zones or disable the pickup zone requirement.", - crate_re_marked = "Re-marking crate {id} with {mark}.", - crate_expired = "Crate {id} expired and was removed.", - crate_max_capacity = "Max load reached ({total}). Drop or build before picking up more.", - crate_aircraft_capacity = "Aircraft capacity reached ({current}/{max} crates). Your {aircraft} can only carry {max} crates.", - troop_aircraft_capacity = "Aircraft capacity reached. Your {aircraft} can only carry {max} troops (you need {count}).", - crate_spawned = "Crate’s live! {type} [{id}]. Bearing {brg}° range {rng} {rng_u}.\nCall for vectors if you need a hand.\n\nTo load: HOVER within 25m at 5-20m AGL, or LAND within 35m and hold for 25s.", - - -- Drops - drop_initiated = "Dropping {count} crate(s) here…", - dropped_crates = "Dropped {count} crate(s) at your location.", - no_loaded_crates = "No loaded crates to drop.", - - -- Build - build_insufficient_crates = "Insufficient crates to build {build}.", - build_requires_ground = "You have {total} crate(s) onboard—drop them first to build here.", - build_started = "Building {build} at your position…", - build_success = "{build} deployed to the field!", - build_success_coalition = "{player} deployed {build} to the field!", - build_failed = "Build failed: {reason}.", - fob_restricted = "FOB building is restricted to designated FOB zones.", - auto_fob_built = "FOB auto-built at {zone}.", - - -- Troops - troops_loaded = "Loaded {count} troops—ready to deploy.", - troops_unloaded = "Deployed {count} troops.", - troops_unloaded_coalition = "{player} deployed {count} troops.", - troops_fast_roped = "Fast-roped {count} troops into the field!", - troops_fast_roped_coalition = "{player} fast-roped {count} troops from {aircraft}!", - no_troops = "No troops onboard.", - troops_deploy_failed = "Deploy failed: {reason}.", - troop_pickup_zone_required = "Move inside a Supply Zone to load troops. Nearest zone is {zone_dist}, {zone_dist_u} away bearing {zone_brg}°.", - troop_load_must_land = "Must be on the ground to load troops. Land and reduce speed to < {max_speed} {speed_u}.", - troop_load_too_fast = "Ground speed too high for loading. Reduce to < {max_speed} {speed_u} (current: {current_speed} {speed_u}).", - troop_unload_altitude_too_high = "Too high for fast-rope deployment. Maximum: {max_agl} m AGL (current: {current_agl} m). Land or descend.", - troop_unload_altitude_too_low = "Too low for safe fast-rope. Minimum: {min_agl} m AGL (current: {current_agl} m). Climb or land.", - - -- Ground auto-load - ground_load_started = "{ground_line}\nHold position for {seconds}s to load {count} crate(s)...", - ground_load_progress = "{ground_line}\n{remaining}s remaining. Hold position.", - ground_load_complete = "{ground_line}\nLoaded {count} crate(s).", - ground_load_aborted = "Ground load aborted: aircraft moved or lifted off.", - ground_load_no_zone = "Ground auto-load requires being inside a Pickup Zone. Nearest zone: {zone_dist} {zone_dist_u} at {zone_brg}°.", - ground_load_no_crates = "No crates within {radius}m to load.", - - -- Coach & nav - vectors_to_crate = "Nearest crate {id}: bearing {brg}°, range {rng} {rng_u}.", - vectors_to_pickup_zone = "Nearest supply zone {zone}: bearing {brg}°, range {rng} {rng_u}.", - coach_enabled = "Hover Coach enabled.", - coach_disabled = "Hover Coach disabled.", - - -- Hover Coach guidance - coach_arrival = "You’re close—nice and easy. Hover at 5–20 meters.", - coach_close = "Reduce speed below 15 km/h and set 5–20 m AGL.", - coach_hint = "{hints} GS {gs} {gs_u}.", - coach_too_fast = "Too fast for pickup: GS {gs} {gs_u}. Reduce below 8 km/h.", - coach_too_high = "Too high: AGL {agl} {agl_u}. Target 5–20 m.", - coach_too_low = "Too low: AGL {agl} {agl_u}. Maintain at least 5 m.", - coach_drift = "Outside pickup window. Re-center within 25 m.", - coach_hold = "Oooh, right there! HOLD POSITION…", - coach_loaded = "Crate is hooked! Nice flying!", - coach_hover_lost = "Movement detected—recover hover to load.", - coach_abort = "Hover lost. Reacquire within 25 m, GS < 8 km/h, AGL 5–20 m.", - - -- Zone state changes - zone_activated = "{kind} Zone {zone} is now ACTIVE.", - zone_deactivated = "{kind} Zone {zone} is now INACTIVE.", - - -- Attack/Defend announcements - attack_enemy_announce = "{unit_name} deployed by {player} has spotted an enemy {enemy_type} at {brg}°, {rng} {rng_u}. Moving to engage!", - attack_base_announce = "{unit_name} deployed by {player} is moving to capture {base_name} at {brg}°, {rng} {rng_u}.", - attack_no_targets = "{unit_name} deployed by {player} found no targets within {rng} {rng_u}. Holding position.", - - jtac_onstation = "JTAC {jtac} on station. CODE {code}.", - jtac_new_target = "JTAC {jtac} lasing {target}. CODE {code}. POS {grid}.", - jtac_target_lost = "JTAC {jtac} lost target. Reacquiring.", - jtac_target_destroyed = "JTAC {jtac} reports target destroyed.", - jtac_idle = "JTAC {jtac} scanning for targets.", - - -- Zone restrictions - drop_forbidden_in_pickup = "Cannot drop crates inside a Supply Zone. Move outside the zone boundary.", - troop_deploy_forbidden_in_pickup = "Cannot deploy troops inside a Supply Zone. Move outside the zone boundary.", - drop_zone_too_close_to_pickup = "Drop Zone creation blocked: too close to Supply Zone {zone} (need at least {need} {need_u}; current {dist} {dist_u}). Fly further away and try again.", - - -- MEDEVAC messages - medevac_crew_spawned = "MEDEVAC REQUEST: {vehicle} crew at {grid}. {crew_size} personnel awaiting rescue. Salvage value: {salvage}.", - medevac_crew_loaded = "Rescued {vehicle} crew ({crew_size} personnel). {vehicle} will respawn shortly.", - medevac_vehicle_respawned = "{vehicle} repaired and returned to the field at original location!", - medevac_crew_delivered_mash = "{player} delivered {vehicle} crew to MASH. Earned {salvage} salvage points! Coalition total: {total}.", - medevac_crew_timeout = "MEDEVAC FAILED: {vehicle} crew at {grid} KIA - no rescue attempted. Vehicle lost.", - medevac_crew_killed = "MEDEVAC FAILED: {vehicle} crew killed in action. Vehicle lost.", - medevac_crew_killed_lines = { - "Stranded Crew ({vehicle} @ {grid}): We're taking heavy fire—where's that bird— *static*", - "Stranded Crew ({vehicle} @ {grid}): Ahh— we're under fire— we can't h— *boom*", - "Stranded Crew ({vehicle} @ {grid}): They're right on top of us— we won't ho— *static*", - "Stranded Crew ({vehicle} @ {grid}): Rounds incoming— this is it boys— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): If you hear this, we didn't make— *static*", - "Stranded Crew ({vehicle} @ {grid}): No more cover! We can't hold— *boom*", - "Stranded Crew ({vehicle} @ {grid}): This is it— tell them we tried— *static*", - "Stranded Crew ({vehicle} @ {grid}): They're all around us— oh God— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): Taking direct hits— we're not walking out of— *static*", - "Stranded Crew ({vehicle} @ {grid}): Where's that ride— we're pinned— we can't— *boom*", - -- New Mo-blame lines - "Stranded Crew ({vehicle} @ {grid}): Mo said this LZ was 'low threat'—remind him how that turned out— *static*", - "Stranded Crew ({vehicle} @ {grid}): Copy, still no bird—did Mo forget to file the MEDEVAC again?— *boom*", - "Stranded Crew ({vehicle} @ {grid}): We only took this route because Mo said it was 'shortcut friendly'— *static*", - "Stranded Crew ({vehicle} @ {grid}): Tell Mo his ‘won’t be hot for long’ brief was a lie— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): Command, if Mo planned this exfil, we’d like a refund— *static*", - "Stranded Crew ({vehicle} @ {grid}): Mo promised we’d be home for chow—guess we’re staying for artillery instead— *boom*", - "Stranded Crew ({vehicle} @ {grid}): We’re still at {grid}—unless Mo ‘optimized’ the coordinates again— *static*", - "Stranded Crew ({vehicle} @ {grid}): Whoever let Mo pick this landing zone owes us new helmets— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): Be advised, Mo’s ‘safe corridor’ is mostly explosions right now— *static*", - "Stranded Crew ({vehicle} @ {grid}): If the bird’s lost, check if Mo touched the map— *boom*", - "Stranded Crew ({vehicle} @ {grid}): This is what we get for trusting Mo’s weather call— *static*", - "Stranded Crew ({vehicle} @ {grid}): Mo swore enemy armor ‘never comes this far’—they’re waving at us— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): We’re painted from every angle—next time, maybe don’t let Mo do the route card— *static*", - "Stranded Crew ({vehicle} @ {grid}): If Mo filed our grid as a coffee break, we’re gonna haunt him— *boom*", - "Stranded Crew ({vehicle} @ {grid}): Radio check—anyone but Mo on comms? We’d like a real pickup— *static*", - "Stranded Crew ({vehicle} @ {grid}): Mo said ‘in and out, easy day’—we’re on hour one of ‘not easy’— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): Still no rotors—ask Mo if he scheduled this MEDEVAC for tomorrow— *static*", - "Stranded Crew ({vehicle} @ {grid}): If Mo’s running the flight schedule, tell him we’re fresh out of patience— *boom*", - "Stranded Crew ({vehicle} @ {grid}): Mo briefed ‘minimal contact’—we’re currently meeting the entire enemy battalion— *static*", - "Stranded Crew ({vehicle} @ {grid}): Rescue bird, if you’re circling, that’s Mo’s navigation, not ours— *explosion*", - "Stranded Crew ({vehicle} @ {grid}): We popped smoke twice—maybe Mo told them to ignore it— *static*", - "Stranded Crew ({vehicle} @ {grid}): If they ask how we got stuck here, just say ‘Mo’—they’ll understand— *boom*", - "Stranded Crew ({vehicle} @ {grid}): Mo said ‘what’s the worst that could happen’—tell him he’s about to find out— *static*", - }, - medevac_no_requests = "No active MEDEVAC requests.", - medevac_vectors = "MEDEVAC: {vehicle} crew bearing {brg}°, range {rng} {rng_u}. Time remaining: {time_remain} mins.", - medevac_salvage_status = "Coalition Salvage Points: {points}. Use salvage to build out-of-stock items.", - medevac_salvage_used = "Built {item} using {salvage} salvage points. Remaining: {remaining}.", - medevac_salvage_insufficient = "Out of stock. Requires {need} salvage points (need {deficit} more). Earn salvage by delivering MEDEVAC crews to MASH or sling-loading enemy wreckage.", - medevac_crew_warn_15min = "WARNING: {vehicle} crew at {grid} - rescue window expires in 15 minutes!", - medevac_crew_warn_5min = "URGENT: {vehicle} crew at {grid} - rescue window expires in 5 minutes!", - medevac_unload_hold = "MEDEVAC: Stay grounded in the MASH zone for {seconds} seconds to offload casualties.", - - -- Sling-Load Salvage messages - slingload_salvage_spawned = "SALVAGE OPPORTUNITY: Enemy wreckage at {grid}. Weight: {weight}kg, Est. Value: {reward}pts. {time_remain} remaining to collect!", - slingload_salvage_delivered = "{player} delivered {weight}kg salvage for {reward} points ({condition})! Coalition total: {total}", - slingload_salvage_expired = "SALVAGE LOST: Crate {id} at {grid} deteriorated.", - slingload_salvage_damaged = "CAUTION: Salvage crate damaged in transit. Value reduced to {reward}pts.", - slingload_salvage_vectors = "Nearest salvage crate {id}: bearing {brg}°, range {rng} {rng_u}. Weight: {weight}kg, Value: {reward}pts.", - slingload_salvage_no_crates = "No active salvage crates available.", - slingload_salvage_zone_created = "Salvage Collection Zone '{zone}' created at your position (radius: {radius}m).", - slingload_salvage_zone_activated = "Salvage Collection Zone '{zone}' is now ACTIVE.", - slingload_salvage_zone_deactivated = "Salvage Collection Zone '{zone}' is now INACTIVE.", - slingload_salvage_warn_30min = "SALVAGE REMINDER: Crate {id} at {grid} expires in 30 minutes. Weight: {weight}kg.", - slingload_manual_crates_registered = "Registered {count} pre-placed salvage crate(s) from mission editor.", - - -- FARP System messages - farp_upgrade_started = "Upgrading FOB to FARP Stage {stage}... Building in progress.", - farp_upgrade_complete = "{player} upgraded FOB to FARP Stage {stage}! Services available: {services}", - farp_upgrade_insufficient_salvage = "Insufficient salvage to upgrade to FARP Stage {stage}. Need {need} points (have {current}). Deliver crews to MASH or sling-load salvage!", - farp_status = "FOB Status: FARP Stage {stage}/{max_stage}\nServices: {services}\nNext upgrade: {next_cost} salvage (Stage {next_stage})", - farp_status_maxed = "FOB Status: FARP Stage {stage}/{max_stage} (FULLY UPGRADED)\nServices: {services}", - farp_not_at_fob = "You must be near a FOB Pickup Zone to upgrade it to a FARP.", - farp_already_maxed = "This FOB is already at maximum FARP stage (Stage 3).", - farp_service_available = "FARP services available: Rearm, Refuel, Repair for ground vehicles and helicopters within {radius}m.", - slingload_salvage_warn_5min = "SALVAGE URGENT: Crate {id} at {grid} expires in 5 minutes!", - slingload_salvage_hooked_in_zone = "Salvage crate {id} is inside {zone}. Release the sling to complete delivery.", - slingload_salvage_wrong_zone = "Salvage crate {id} is sitting in {zone_type} zone {zone}. Take it to an active Salvage zone for credit.", - slingload_salvage_received_quips = { - "{player}: Leroy just whispered that's the smoothest receiving job he's seen since Jenkins tried the backwards hover.", - "Jenkins radios {player}, 'Keep receiving cargo like that and Mo might finally stop polishing his hook.'", - "Mo mutters, 'I thought we were receiving ammo, not a love letter, {player}.'", - "Leroy is yelling 'RECEIVE IT' like we're back at basic—nice work, {player}.", - "Jenkins keeps a scoreboard titled 'Most dramatic receiving'. Congrats, {player}, you're on top.", - "Mo claims he can hear the crate blush when {player} talks about receiving it.", - "Leroy swears the secret to receiving cargo is wink twice and yell 'Jenkins!'—apparently {player} nailed it.", - "Jenkins asked if {player} offers receiving lessons; I told him to practice on Mo's toolbox first.", - "Mo's diary entry tonight: '{player} received cargo and my rotor wash feelings.'", - "Leroy just gave {player} a 'World's Best Receiver' sticker; don't ask where he kept it.", - "Jenkins would like to know how {player} made receiving cargo look like interpretive dance.", - "Mo says the crate purred when {player} set it down. I blame Leroy's commentary.", - "Leroy keeps chanting 'receiver of the year' while pointing at {player}. Jenkins is jealous.", - "Jenkins tried to high five {player} mid-receive; Mo tackled him for safety reasons (probably).", - "Mo claims {player}'s receiving form violates at least three flirtation regulations.", - "Leroy just filed paperwork changing {player}'s callsign to 'Receiver Prime'.", - "Jenkins bet Mo twenty bucks {player} would blush while receiving. Pay up, Mo.", - "Mo suggests engraving {player}'s name on the salvage zone after that receive.", - "Leroy is teaching a receiving clinic now, curriculum: 'Be {player}'.", - "Jenkins whispered, 'Was it good for you, cargo?' right after {player} touched down.", - "Mo's clipboard has doodles of {player} receiving crates with heroic sparkles.", - "Leroy just threw fake rose petals over the zone as {player} finished receiving.", - "Jenkins swears he heard a saxophone during that receive. You owe the band, {player}.", - "Mo radioed, 'Easy on the receiving, {player}. Jenkins can't handle that much elegance.'", - "Leroy said {player}'s receiving style is 'spicy yet responsible'. I'm both impressed and confused.", - "Jenkins taped a 'Certified Receiver' badge on {player}'s dash. It's glittery. You're stuck with it.", - "Mo reckons {player} could receive a crate blindfolded; Leroy begged to test that theory.", - "Leroy keeps shouting 'stick the landing' but {player} already did—twice.", - "Jenkins requested that {player} narrate the next receiving so he can take notes.", - "Mo says if {player} receives one more crate like that, he'll start writing poetry.", - "Leroy just named this salvage drop 'Operation Receive and Believe' in {player}'s honor.", - "Jenkins said '{player} receives cargo like it's a surprise party and everyone's invited.'", - "Mo's new checklist: 1) Fuel 2) Ammo 3) Compliment {player}'s receiving technique.", - "Leroy tried to slow clap mid-receive; Jenkins confiscated his gloves.", - "Jenkins asked if {player} rehearsed that receive with mirrors. Mo said 'jealous much?'.", - "Mo beams, 'If receiving had judges, {player} just earned a 10 from Leroy and a dramatic sigh from Jenkins.'", - "Leroy told {player} to teach Jenkins how to receive without dropping his dignity.", - "Jenkins insists {player}'s receiving aura smells like jet fuel and confidence.", - "Mo logged today's weather as 'partly cloudy with a 100% chance of {player} receiving nicely.'", - "Leroy carved '{player} receives like a legend' into the ready room table. Again.", - "Jenkins wrote a haiku about that receiving: 'Crate softly descends / {player} grins / Mo steals the pen.'", - "Mo radioed 'next crate volunteers to be received by {player} only'. That's a policy now.", - "Leroy just asked supply for a 'Receiver of Renown' patch for {player}.", - "Jenkins claims he can feel the cargo sigh happily whenever {player} receives it.", - "Mo's schedule: 0900 watch {player} receive cargo, 0915 tease Jenkins about it.", - "Leroy calls {player}'s receiving style 'buttery smooth'. Jenkins now refuses to eat butter.", - "Jenkins left a sticky note on the crate: 'Thanks for receiving me gently, {player}.'", - "Mo wants to rename the salvage zone 'The {player} Receiving Lounge'.", - "Leroy drew hearts on the map where {player} usually receives cargo. Tactical? Maybe.", - "Jenkins petitioned command to play theme music every time {player} receives.", - "Mo refuses to stop chuckling about the way {player} said 'receiving' over comms.", - "Leroy says {player}'s receiving vibe is 'half heroics, half mischief'. Jenkins says 'all jealous'.", - "Jenkins asked if {player} could autograph the crate before sending it to depots.", - "Mo declared 'receiving is believing' after watching {player} today.", - "Leroy reenacting {player}'s receiving on the ramp is the finest comedy we've had all week.", - "Jenkins keeps practicing saying 'receiving' with the same swagger {player} has.", - "Mo whispers, 'Every time {player} receives cargo, a wrench somewhere falls in love.'", - "Leroy filed a noise complaint: 'Jenkins screaming while {player} receives is distracting.'", - "Jenkins now greets crates with 'prepare to be received by {player}—lucky you.'", - "Mo replaced the zone sign with 'Please announce yourself before {player} receives you.'", - "Leroy says the salvage crate winked at {player}. Jenkins is skeptical but also jealous.", - "Jenkins claims {player}'s receiving technique could calm a startled MANPAD crew.", - "Mo has a coffee mug that reads 'I watched {player} receive cargo and all I got was this latte.'", - "Leroy tried to choreograph a receiving dance for {player}; Jenkins tripped at step two.", - "Jenkins keeps bragging he called dibs on being {player}'s receiving hype man.", - "Mo scribbled 'receiving champion' under {player}'s name on the roster.", - "Leroy says {player}'s receiving voice has more bass than the AWACS channel.", - "Jenkins asked supply for velvet ropes to keep crowds away while {player} receives.", - "Mo's new ringtone is {player} saying 'receiving'. Leroy set it as his alarm too.", - "Leroy's advice to rookies: 'Just copy whatever {player} does when receiving.'", - "Jenkins says he needs sunglasses to watch {player} receive because it's that dazzling.", - "Mo keeps muttering 'receiving goals' every time {player} touches down in the zone.", - "Leroy left chalk arrows around the FARP labeled 'This way to watch {player} receive.'", - "Jenkins insists the crate asked for {player}'s comm frequency after that receive.", - "Mo bet the maintenance crew that {player} could receive cargo upside down. Please don't try it.", - "Leroy's latest call sign suggestion for {player}: 'Receiver Supreme'.", - "Jenkins laminated a card that says 'Ask me about {player}'s receiving technique.'", - "Mo says the wind socks lean toward {player} out of respect whenever receiving begins.", - "Leroy drew a cartoon of {player} receiving cargo while Jenkins fans them with a checklist.", - "Jenkins keeps replaying the cockpit tape of {player} saying 'receiving' on loop.", - "Mo told supply to send extra polish for the pad because {player} only receives on shiny decks.", - "Leroy swears he heard the crate whisper 'thanks, {player}' as it touched down.", - "Jenkins offers motivational speeches to crates before {player} arrives, just to set the mood.", - "Mo added 'compliment {player}'s receiving tone' to the preflight checklist.", - "Leroy refuses to call it unloading anymore; it's 'the {player} receiving ritual'.", - "Jenkins keeps practicing finger guns for when {player} calls 'receiving'.", - "Mo doodled {player} surfing on a crate labeled 'Receiving Champion'.", - "Leroy renamed the F10 menu option to 'Let {player} receive it'. QA is furious.", - "Jenkins says whenever {player} receives cargo, somewhere a sim pilot sheds a proud tear.", - "Mo now greets every crate with 'Ready to be received {player}-style?'.", - "Leroy told the intel guys to log {player}'s receiving pattern as a morale asset.", - "Jenkins claims {player}'s receiving aura smells like hydraulic fluid and heroics.", - "Mo keeps a chalkboard tally titled '{player}'s Receives vs Jenkins' Complaints'. Receives win.", - "Leroy is convinced {player}'s receiving callouts add five knots to every rotorcraft nearby.", - "Jenkins practices saying 'nice receiving' in the mirror so he doesn't squeak on comms.", - "Mo decorated the salvage zone with disco lights for the next time {player} receives.", - "Leroy asked PAO for a documentary called 'Receiving with {player}'.", - "Jenkins swears {player}'s receiving cadence syncs perfectly with the base siren. Spooky.", - "Mo now ends every debrief with 'and that's how {player} receives cargo, folks.'", - "Leroy wrote a limerick about {player} receiving; Jenkins begged him not to recite it.", - "Jenkins told the new guy 'if {player} says receiving, salute the crate.'", - "Mo keeps drawing diagrams titled 'Ideal Receiving Cones' with {player} stick figures.", - "Leroy asked meteorology for a forecast of '{player} receiving with 100% sass'.", - "Jenkins says hearing {player} say 'receiving' cured his fear of autorotations.", - "Mo replaced the salvage beacon tone with {player} humming the receiving tune.", - "Leroy told Jenkins, 'If you can't receive like {player}, at least clap rhythmically.'", - "Jenkins is workshopping a catchphrase: '{player} receives, Mo believes, Leroy deceives.'", - "Mo says the only thing smoother than {player}'s receiving is Leroy's questionable pickup lines.", - "Leroy wants to build a statue of {player} holding a crate; Jenkins suggested maybe start with a sticker.", - "Jenkins insists {player}'s receiving voice lowers enemy morale by 10%. Science-ish.", - "Mo has a bingo card of {player}'s receiving compliments; he's already got blackout.", - "Leroy now ends every briefing with 'remember, let {player} receive first'.", - "Jenkins swears his headset auto-tunes whenever {player} says 'receiving'.", - "Mo just said, 'If receiving had medals, {player} would need a bigger flight suit.'", - "Leroy warns crates, 'If {player} receives you, expect fireworks and Jenkins squealing.'", - "Jenkins' new mantra: 'Breathe in rotor wash, breathe out {player}'s receiving tips.'", - "Mo says {player}'s receiving swagger should be issued with the flight manual.", - "Leroy doodled {player} riding a crate labeled 'Receiving Express'—Mo framed it.", - "Jenkins told logistics, 'Mark that crate FRAGILE, {player} receives with feelings.'", - "Mo now pronounces 'receiving' with extra syllables whenever {player} is on station.", - "Leroy thinks {player}'s receiving just added three morale points to the whole AO.", - "Jenkins said he'd write a sonnet about {player}'s receiving if he knew what a sonnet was.", - "Mo just asked if we could get {player} to receive the mail too, because wow.", - "Leroy polished the landing zone so {player}'s next receiving feels fancy.", - "Jenkins is holding up scorecards every time {player} says 'receiving'. The numbers keep climbing.", - "Mo claims {player}'s receiving is now a controlled substance in three states.", - "Leroy told everyone to clear the pad—'{player} needs room to receive with flair!'.", - "Jenkins thinks {player}'s receiving tone should replace the standard 'cargo secure' call.", - "Mo concluded today's briefing with 'Step 1: Let {player} receive. Step 2: Profit.'", - "Leroy keeps whispering 'look how {player} receives' like it's a wildlife documentary.", - "Jenkins renamed his playlist 'Songs to Receive Cargo Like {player}'.", - "Mo's checklist scribble says 'Remember to compliment {player} after receiving'.", - "Leroy just added 'receiving swagger' to the SOP because of {player}.", - "Jenkins insists {player}'s receiving posture cured his back pain. Untested claim.", - "Mo now uses '{player}-level receiving' as a unit of measurement for excellence.", - "Leroy stuck a note on the crate: 'You were received by {player}. You're welcome.'", - "Jenkins laughed so hard at {player}'s receiving banter that he nearly dropped his clipboard.", - "Mo just declared {player} the patron saint of receiving, halo made of tie-down straps.", - "Leroy vows to shout 'RECEIVE IT' every time {player} descends, purely for morale.", - "Jenkins says {player}'s receiving swagger should be bottled and issued to recruits.", - }, - medevac_unload_aborted = "MEDEVAC: Unload aborted - {reason}. Land and hold for {seconds} seconds.", - - -- Mobile MASH messages - medevac_mash_deployed = "Mobile MASH {mash_id} deployed at {grid}. Beacon: {freq}. Delivering MEDEVAC crews here earns salvage points.", - medevac_mash_announcement = "Mobile MASH {mash_id} available at {grid}. Beacon: {freq}.", - medevac_mash_destroyed = "Mobile MASH {mash_id} destroyed! No longer accepting deliveries.", - mash_announcement = "MASH {name} operational at {grid}. Accepting MEDEVAC deliveries for salvage credit. Monitoring {freq} AM.", - mash_vectors = "Nearest MASH: {name} at bearing {brg}°, range {rng} {rng_u}.", - mash_no_zones = "No MASH zones available.", -} - --- #endregion Messaging - -CTLD.Config = { - -- === Instance & Access === - CoalitionSide = coalition.side.BLUE, -- default coalition this instance serves (menus created for this side) - CountryId = nil, -- optional explicit country id for spawned groups; falls back per coalition - AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB) - 'UH-1H','Mi-8MTV2','Mi-24P','SA342M','SA342L','SA342Minigun','Ka-50','Ka-50_3','AH-64D_BLK_II','UH-60L','CH-47Fbl1','CH-47F','Mi-17','GazelleAI' - }, - -- === Runtime & Messaging === - -- Logging control: set the desired level of detail for env.info logging to DCS.log - -- 0 = NONE - No logging at all (production servers) - -- 1 = ERROR - Only critical errors and warnings - -- 2 = INFO - Important state changes, initialization, cleanup (default for production) - -- 3 = VERBOSE - Detailed operational info (zone validation, menus, builds, MEDEVAC events) - -- 4 = DEBUG - Everything including hover checks, crate pickups, detailed troop spawns - LogLevel = 4, -- lowered from DEBUG (4) to INFO (2) for production performance - MessageDuration = 15, -- seconds for on-screen messages - - -- Debug toggles for detailed crate proximity logging (useful when tuning hover coach / ground autoload) - DebugHoverCrates = false, - DebugHoverCratesInterval = 1.0, -- seconds between hover debug log bursts (per aircraft) - DebugHoverCratesStep = 25, -- log again when nearest crate distance changes by this many meters - DebugGroundCrates = false, - DebugGroundCratesInterval = 2.0, -- seconds between ground debug log bursts (per aircraft) - DebugGroundCratesStep = 10, -- log again when nearest crate distance changes by this many meters - - -- === Menu & Catalog === - UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide (leave this alone) - CreateMenuAtMissionStart = false, -- creates empty root menu at mission start to reserve F10 position (populated on player spawn) - RootMenuName = 'CTLD', -- name for the root F10 menu; menu ordering depends on script load order in mission editor - UseCategorySubmenus = true, -- if true, organize crate requests by category submenu (menuCategory) - UseBuiltinCatalog = false, -- start with the shipped catalog (true) or expect mission to load its own (false) - - -- === Transport Capacity === - -- Default capacities for aircraft not listed in AircraftCapacities table - -- Used as fallback for any transport aircraft without specific limits defined - DefaultCapacity = { - maxCrates = 4, -- reasonable middle ground - maxTroops = 12, -- moderate squad size - maxWeightKg = 2000, -- default weight capacity in kg (omit to disable weight modeling) - }, - - -- Per-aircraft capacity limits (realistic cargo/troop capacities) - -- Set maxCrates = 0 and maxTroops = 0 for attack helicopters with no cargo capability - -- If an aircraft type is not listed here, it will use DefaultCapacity values - -- maxWeightKg: optional weight capacity in kilograms (if omitted, only count limits apply) - -- requireGround: optional override for ground requirement (true = must land, false = can load in hover/flight) - -- maxGroundSpeed: optional override for max ground speed during loading (m/s) - AircraftCapacities = { - -- Small/Light Helicopters (very limited capacity) - ['SA342M'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, -- Gazelle - tiny observation/scout helo - ['SA342L'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, - ['SA342Minigun'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, - ['GazelleAI'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, - - -- Attack Helicopters (no cargo capacity - combat only) - ['Ka-50'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark - single seat attack - ['Ka-50_3'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark 3 - ['AH-64D_BLK_II'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Apache - attack/recon only - ['Mi-24P'] = { maxCrates = 2, maxTroops = 8, maxWeightKg = 1000 }, -- Hind - attack helo but has small troop bay - - -- Light Utility Helicopters (moderate capacity) - ['UH-1H'] = { maxCrates = 3, maxTroops = 11, maxWeightKg = 1800 }, -- Huey - classic light transport - - -- Medium Transport Helicopters (good capacity) - ['Mi-8MTV2'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip - Russian medium transport - ['Mi-17'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip variant - ['UH-60L'] = { maxCrates = 4, maxTroops = 11, maxWeightKg = 4000 }, -- Black Hawk - medium utility - - -- Heavy Lift Helicopters (maximum capacity) - ['CH-47Fbl1'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook - heavy lift beast - ['CH-47F'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook variant - - -- Fixed Wing Transport (limited capacity) - -- NOTE: C-130 has requireGround configurable - set to false if you want to allow in-flight loading (unrealistic but flexible) - ['C-130'] = { maxCrates = 20, maxTroops = 92, maxWeightKg = 20000, requireGround = true, maxGroundSpeed = 1.0 }, -- C-130 Hercules - tactical airlifter (must be fully stopped) - ['C-17A'] = { maxCrates = 30, maxTroops = 150, maxWeightKg = 77500, requireGround = true, maxGroundSpeed = 1.0 }, -- C-17 Globemaster III - strategic airlifter - }, - - -- === Loading & Deployment Rules === - RequireGroundForTroopLoad = true, -- must be landed to load troops (prevents loading while hovering) - RequireGroundForVehicleLoad = true, -- must be landed to load vehicles (C-130/large transports) - MaxGroundSpeedForLoading = 2.0, -- meters/second limit while loading (roughly 4 knots) - - -- Fast-rope deployment (allows troop unload while hovering at safe altitude) - EnableFastRope = true, -- if true, troops can fast-rope from hovering helicopters - FastRopeMaxHeight = 20, -- meters AGL: maximum altitude for fast-rope deployment - FastRopeMinHeight = 5, -- meters AGL: minimum altitude for fast-rope deployment (too low = collision risk) - - -- Safety offsets to avoid spawning units too close to player aircraft - BuildSpawnOffset = 40, -- meters: shift build point forward from the aircraft (0 = spawn centered on aircraft) - TroopSpawnOffset = 25, -- meters: shift troop unload point forward from the aircraft - DropCrateForwardOffset = 35, -- meters: drop loaded crates this far in front of the aircraft - - -- === Build & Crate Handling === - BuildRequiresGroundCrates = true, -- required crates must be on the ground (not still carried) - BuildRadius = 100, -- meters around build point to collect crates - BuildDispersionRadius = 30, -- meters: randomize spawn positions within this radius (Build All mode only; 0 = disable) - RestrictFOBToZones = false, -- only allow FOB recipes inside configured FOBZones - AutoBuildFOBInZones = false, -- auto-build FOB recipes when required crates are inside a FOB zone - CrateLifetime = 3600, -- seconds before crates auto-clean up; 0 = disable - - -- Build safety - BuildConfirmEnabled = false, -- require a second confirmation within a short window before building - BuildConfirmWindowSeconds = 30, -- seconds allowed between first and second "Build Here" press - BuildCooldownEnabled = true, -- impose a cooldown before allowing another build by the same group - BuildCooldownSeconds = 0, -- seconds of cooldown after a successful build per group - - -- === Pickup & Drop Zone Rules === - RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone - RequirePickupZoneForTroopLoad = true, -- troops can only be loaded while inside a Supply (Pickup) Zone - PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request - ForbidDropsInsidePickupZones = true, -- block crate drops while inside a Pickup Zone - ForbidTroopDeployInsidePickupZones = true, -- block troop deploy while inside a Pickup Zone - ForbidChecksActivePickupOnly = true, -- when true, restriction applies only to ACTIVE pickup zones; false blocks all configured pickup zones - - -- Dynamic Drop Zone settings - DropZoneRadius = 500, -- meters: radius used when creating a Drop Zone via the admin menu at player position - MinDropZoneDistanceFromPickup = 2000, -- meters: minimum distance from nearest Pickup Zone required to create a dynamic Drop Zone (0 to disable) - MinDropDistanceActivePickupOnly = true, -- when true, only ACTIVE pickup zones are considered for the minimum distance check - - -- === Pickup Zone Spawn Placement === - PickupZoneSpawnRandomize = true, -- spawn crates at a random point within the pickup zone (avoids stacking) - PickupZoneSpawnEdgeBuffer = 20, -- meters: keep spawns at least this far inside the zone edge - PickupZoneSpawnMinOffset = 75, -- meters: keep spawns at least this far from the exact center - CrateSpawnMinSeparation = 7, -- meters: try not to place a new crate closer than this to an existing one - CrateSpawnSeparationTries = 6, -- attempts to find a non-overlapping position before accepting best effort - CrateClusterSpacing = 8, -- meters: spacing used when clustering crates within a bundle - PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones - - -- Crate Smoke Settings - -- NOTE: Individual smoke effects last ~5 minutes (DCS hardcoded, cannot be changed) - -- These settings control whether/how often NEW smoke is spawned, not how long each smoke lasts - CrateSmoke = { - Enabled = true, -- spawn smoke when crates are created; if false, no smoke at all - AutoRefresh = false, -- automatically spawn new smoke every RefreshInterval seconds - RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true) - MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit) - OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke - OffsetRandom = true, -- if true, randomize horizontal offset direction; if false, always offset north - OffsetVertical = 20, -- meters: vertical offset above ground level (helps smoke be more visible) - }, - - -- === Autonomous Assets === - -- Air-spawn settings for CTLD-built drones (AIRPLANE catalog entries like MQ-9 / WingLoong) - DroneAirSpawn = { - Enabled = true, -- when true, AIRPLANE catalog items that opt-in can spawn in the air at a set altitude - AltitudeMeters = 5000, -- default spawn altitude ASL (meters) - SpeedMps = 120 -- default initial speed in m/s - }, - - JTAC = { - Enabled = true, - Verbose = false, -- when true, emit detailed JTAC registration & target scan logs - AutoLase = { - Enabled = true, - SearchRadius = 8000, -- meters to scan for enemy targets - RefreshSeconds = 15, -- seconds between active target updates - IdleRescanSeconds = 30, -- seconds between scans when no target locked - LostRetrySeconds = 10, -- wait before trying to reacquire after transport/line-of-sight loss - TransportHoldSeconds = 10, -- defer lase loop while JTAC is in transport (group empty) - }, - Smoke = { - Enabled = true, - ColorBlue = trigger.smokeColor.Orange, - ColorRed = trigger.smokeColor.Green, - RefreshSeconds = 300, -- seconds between smoke refreshes on active targets - OffsetMeters = 5, -- random offset radius for smoke placement - }, - LaserCodes = { '1688','1677','1666','1113','1115','1111' }, - LockType = 'all', -- 'all' | 'vehicle' | 'troop' - Announcements = { - Enabled = true, - Duration = 15, - }, - - }, - - -- === Combat Automation === - AttackAI = { - Enabled = true, -- master switch for attack behavior - TroopSearchRadius = 6000, -- meters: when deploying troops with Attack, search radius for targets/bases - VehicleSearchRadius = 12000, -- meters: when building vehicles with Attack, search radius - PrioritizeEnemyBases = true, -- if true, prefer enemy-held bases over ground units when both are in range - -- Smart omniscient targeting: when true, LOS / DCS detection quirks are ignored for target *selection*. - -- The script will always pick the nearest valid enemy/base within the configured radius and order a move - -- toward it. DCS AI LOS still governs when units can actually fire once they get there. - SmartTargeting = true, - TroopAdvanceSpeedKmh = 20, -- movement speed for troops when ordered to attack - VehicleAdvanceSpeedKmh = 35, -- movement speed for vehicles when ordered to attack - }, - - -- === Visual Aids === - -- Optional: draw zones on the F10 map using trigger.action.* markup (ME Draw-like) - MapDraw = { - Enabled = true, -- master switch for any map drawings created by this script - DrawPickupZones = true, -- draw Pickup/Supply zones as shaded circles with labels - DrawDropZones = true, -- optionally draw Drop zones - DrawFOBZones = true, -- optionally draw FOB zones - DrawMASHZones = true, -- optionally draw MASH (medical) zones - DrawSalvageZones = true, -- optionally draw Salvage Collection zones - FontSize = 18, -- label text size - ReadOnly = true, -- prevent clients from removing the shapes - ForAll = false, -- if true, draw shapes to all (-1) instead of coalition only (useful for testing/briefing) - OutlineColor = {1, 1, 0, 0.85}, -- RGBA 0..1 for outlines (bright yellow) - -- Optional per-kind fill overrides - FillColors = { - Pickup = {0, 1, 0, 0.15}, -- light green fill for Pickup zones - Drop = {0, 0, 0, 0.25}, -- black fill for Drop zones - FOB = {1, 1, 0, 0.15}, -- yellow fill for FOB zones - MASH = {1, 0.75, 0.8, 0.25}, -- pink fill for MASH zones - SalvageDrop = {1, 0, 1, 0.15}, -- magenta fill for Salvage zones - }, - LineType = 1, -- default line type if per-kind is not set (0 None, 1 Solid, 2 Dashed, 3 Dotted, 4 DotDash, 5 LongDash, 6 TwoDash) - LineTypes = { -- override border style per zone kind - Pickup = 3, -- dotted - Drop = 2, -- dashed - FOB = 4, -- dot-dash - MASH = 1, -- solid - SalvageDrop = 2, -- dashed - }, - -- Label placement tuning (simple): - -- Effective extra offset from the circle edge = r * LabelOffsetRatio + LabelOffsetFromEdge - LabelOffsetFromEdge = -50, -- meters beyond the zone radius to place the label (12 o'clock) - LabelOffsetRatio = 0.5, -- fraction of the radius to add to the offset (e.g., 0.1 => +10% of r) - LabelOffsetX = 200, -- meters: horizontal nudge; adjust if text appears left-anchored in your DCS build - -- Per-kind label prefixes - LabelPrefixes = { - Pickup = 'Supply', - Drop = 'Drop', - FOB = 'FOB', - MASH = 'MASH', - SalvageDrop = 'Salvage', - } - }, - - -- === Inventory & Troops === - -- Inventory system (per pickup zone and FOBs) - Inventory = { - Enabled = true, -- master switch for per-location stock control - FOBStockFactor = 0.50, -- starting stock at newly built FOBs relative to pickup-zone initialStock - ShowStockInMenu = true, -- append simple stock hints to menu labels (per current nearest zone) - HideZeroStockMenu = false, -- removed: previously created an "In Stock Here" submenu; now disabled by default - }, - - -- Troop type presets (menu-driven loadable teams) - Troops = { - DefaultType = 'AS', -- default troop type to use when no specific type is chosen - -- Team definitions: loaded from catalog via _CTLD_TROOP_TYPES global - -- If no catalog is loaded, empty table is used (and fallback logic applies) - TroopTypes = {}, - }, - - -- === Zone Tables === - -- Mission makers should populate these arrays with zone definitions - -- Each zone entry can be: { name = 'ZoneName' } or { name = 'ZoneName', flag = 9001, activeWhen = 0, smoke = color, radius = meters } - Zones = { - PickupZones = {}, -- Supply zones where crates/troops can be requested - DropZones = {}, -- Optional Drop/AO zones - FOBZones = {}, -- FOB zones (restrict FOB building to these if RestrictFOBToZones = true) - MASHZones = {}, -- Medical zones for MEDEVAC crew delivery (MASH = Mobile Army Surgical Hospital) - SalvageDropZones = {}, -- Salvage collection zones for sling-load salvage delivery - }, - - -- === Sling-Load Salvage System === - -- Spawn salvageable crates when enemy units are destroyed; deliver to collection zones for rewards - SlingLoadSalvage = { - Enabled = true, - - -- Manual salvage crates (pre-placed in mission editor) - EnableManualCrates = true, -- Scan for and register pre-placed cargo statics as salvage - ManualCratePrefix = 'SALVAGE-', -- Only cargo statics starting with this prefix are registered - - -- Spawn probability when enemy ground units die - SpawnChance = { - [coalition.side.BLUE] = 0.20, -- 20% chance when BLUE unit dies (RED can collect the salvage) - [coalition.side.RED] = 0.20, -- 20% chance when RED unit dies (BLUE can collect the salvage) - }, - - -- Weight classes with spawn probabilities and reward rates - WeightClasses = { - { name = 'Light', min = 500, max = 1000, probability = 0.50, rewardPer500kg = 0.5 }, -- 1-2 pts (reduced from 2) - { name = 'Medium', min = 2501, max = 5000, probability = 0.30, rewardPer500kg = 1 }, -- 5-10 pts (reduced from 3) - { name = 'Heavy', min = 5001, max = 8000, probability = 0.15, rewardPer500kg = 1.5 }, -- 15-24 pts (reduced from 5) - { name = 'SuperHeavy', min = 8001, max = 12000, probability = 0.05, rewardPer500kg = 2 }, -- 32-48 pts (reduced from 8) - }, - - -- Condition-based reward multipliers (based on crate health when delivered) - ConditionMultipliers = { - Undamaged = 1.5, -- >= 90% health - Damaged = 1.0, -- 50-89% health - HeavyDamage = 0.5, -- < 50% health - }, - - CrateLifetime = 3600, -- 1 hour (seconds) - WarningTimes = { 1800, 300 }, -- Warn at 30min and 5min remaining - - -- Visual indicators - SpawnSmoke = false, - SmokeDuration = 120, -- 2 minutes - SmokeColor = trigger.smokeColor.Orange, - MaxActiveCrates = 40, -- hard cap on simultaneously spawned salvage crates per coalition - AdaptiveIntervals = { idle = 10, low = 20, medium = 25, high = 30 }, - - -- Spawn restrictions - MinSpawnDistance = 25, -- meters from death location - MaxSpawnDistance = 45, -- meters from death location - NoSpawnNearPickupZones = true, - NoSpawnNearPickupZoneDistance = 1000, -- meters - NoSpawnNearAirbasesKm = 1, - - DetectionInterval = 5, -- seconds between salvage zone checks - - -- Cargo static types (DCS sling-loadable cargo) - CargoTypes = { - 'container_cargo', - 'ammo_cargo', - 'fueltank_cargo', - 'barrels_cargo', - 'uh1h_cargo', - 'pipes_small_cargo', - 'pipes_big_cargo', - 'tetrapod_cargo', - 'trunks_small_cargo', - 'trunks_long_cargo', - 'oiltank_cargo', - 'f_bar_cargo', - 'm117_cargo', - }, - - -- Salvage Collection Zone defaults - DefaultZoneRadius = 300, - DynamicZoneLifetime = 5400, -- seconds a player-created zone stays active (0 disables auto-expiry) - MaxDynamicZones = 6, -- cap player-created zones per coalition instance (oldest retire first) - ZoneColors = { - border = {1, 0.5, 0, 0.85}, -- orange border - fill = {1, 0.5, 0, 0.15}, -- light orange fill - }, - }, -} - -- #endregion Config - --- ========================= --- FARP System Configuration --- ========================= --- Progressive FOB->FARP upgrade system with static object layouts -CTLD.FARPConfig = { - Enabled = true, - - -- Salvage costs for each stage upgrade - StageCosts = { - [1] = 3, -- FOB -> Stage 1 FARP (basic pad) - [2] = 5, -- Stage 1 -> Stage 2 (operational fuel) - [3] = 8, -- Stage 2 -> Stage 3 (full forward airbase) - }, - - -- Service zone radius for rearm/refuel at each stage - ServiceRadius = { - [1] = 50, -- Stage 1: basic pad only - [2] = 65, -- Stage 2: fuel operations - [3] = 80, -- Stage 3: full services - }, - - -- Static object layouts for each FARP stage - -- Format: { type = "DCS_Static_Name", x = offset_x, z = offset_z, heading = degrees, height = 0 } - -- Positions are relative to FOB center point - StageLayouts = { - -- Stage 1: Basic FARP Pad (3 salvage) - [1] = { - { type = "FARP CP Blindage", x = 0, z = 25, heading = 180 }, - { type = "FARP Tent", x = 17.3, z = 10, heading = 240 }, - { type = "FARP Tent", x = -17.3, z = 10, heading = 120 }, - { type = "container_20ft", x = 15.4, z = -6.2, heading = 90 }, - { type = "container_20ft", x = -15.4, z = -6.2, heading = 90 }, - { type = "Windsock", x = 0, z = 30, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = 13, z = -10.6, heading = 30 }, - { type = "FARP Ammo Dump Coating", x = -13, z = -10.6, heading = 330 }, - { type = ".Ammunition depot", x = 17, z = 12, heading = 0 }, - { type = ".Ammunition depot", x = -17, z = 12, heading = 0 }, - { type = "BarrelCargo", x = 8, z = -18, heading = 0 }, - { type = "BarrelCargo", x = -8, z = -18, heading = 0 }, - { type = "BarrelCargo", x = 12, z = 20, heading = 0 }, - { type = "BarrelCargo", x = -12, z = 20, heading = 0 }, - { type = "GeneratorF", x = 22, z = -3, heading = 270 }, - { type = "Sandbox", x = 10, z = -16, heading = 0 }, - { type = "Sandbox", x = -10, z = -16, heading = 0 }, - { type = "Sandbox", x = 10, z = 16, heading = 0 }, - { type = "Sandbox", x = -10, z = 16, heading = 0 }, - { type = "Sandbox", x = 18, z = 0, heading = 0 }, - { type = "Sandbox", x = -18, z = 0, heading = 0 }, - }, - - -- Stage 2: Operational FARP - adds fuel capability (5 more salvage) - [2] = { - { type = "M978 HEMTT Tanker", x = 35, z = 0, heading = 270 }, - { type = "M978 HEMTT Tanker", x = -35, z = 0, heading = 90 }, - { type = "FARP Fuel Depot", x = 40, z = -8, heading = 0 }, - { type = "FARP Fuel Depot", x = -40, z = -8, heading = 0 }, - { type = "FARP Tent", x = 26.5, z = 21.7, heading = 210 }, - { type = "FARP Tent", x = -26.5, z = 21.7, heading = 150 }, - { type = "container_40ft", x = 0, z = -35, heading = 0 }, - { type = "container_40ft", x = 8, z = -35, heading = 0 }, - { type = "Hesco_wallperimeter_7", x = 0, z = 42, heading = 0 }, - { type = "Hesco_wallperimeter_7", x = 30, z = 30, heading = 315 }, - { type = "Hesco_wallperimeter_7", x = -30, z = 30, heading = 45 }, - { type = "Red_Flag", x = 0, z = 45, heading = 0 }, - { type = "Red_Flag", x = 45, z = 0, heading = 0 }, - { type = "Red_Flag", x = -45, z = 0, heading = 0 }, - { type = "Red_Flag", x = 0, z = -45, heading = 0 }, - { type = "Ural-375 PBU", x = 32.9, z = -13.1, heading = 225 }, - { type = "Electric power box", x = 28, z = 20, heading = 0 }, - { type = "Electric power box", x = -28, z = 20, heading = 0 }, - { type = "Landmine pot", x = 36, z = 8, heading = 0 }, - { type = "Landmine pot", x = -36, z = 8, heading = 0 }, - { type = "Landmine pot", x = 30, z = -25, heading = 0 }, - { type = "Landmine pot", x = -30, z = -25, heading = 0 }, - { type = "Landmine pot", x = 20, z = 30, heading = 0 }, - { type = "Landmine pot", x = -20, z = 30, heading = 0 }, - { type = "Tetrapod", x = 42, z = 15, heading = 0 }, - { type = "Tetrapod", x = -42, z = 15, heading = 0 }, - { type = "Tetrapod", x = 42, z = -15, heading = 0 }, - { type = "Tetrapod", x = -42, z = -15, heading = 0 }, - { type = "Tetrapod", x = 15, z = 42, heading = 0 }, - { type = "Tetrapod", x = -15, z = 42, heading = 0 }, - { type = "Tetrapod", x = 15, z = -42, heading = 0 }, - { type = "Tetrapod", x = -15, z = -42, heading = 0 }, - { type = "FARP Command Post", x = 0, z = 25, heading = 180 }, - }, - - -- Stage 3: Full Forward Airbase - adds ammo and comms (8 more salvage) - [3] = { - { type = "M939 Heavy", x = 38.9, z = -21.9, heading = 225 }, - { type = "M939 Heavy", x = -38.9, z = -21.9, heading = 135 }, - { type = "Shelter", x = 0, z = -50, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = 48, z = -10, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = -48, z = -10, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = 45, z = -20, heading = 0 }, - { type = "FARP Ammo Dump Coating", x = -45, z = -20, heading = 0 }, - { type = "SKP-11", x = 0, z = 55, heading = 180 }, - { type = "ZiL-131 APA-80", x = 8, z = 52, heading = 180 }, - { type = "Hesco_wallperimeter_1", x = 52, z = 30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = -52, z = 30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = 52, z = -30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = -52, z = -30, heading = 0 }, - { type = "Hesco_wallperimeter_1", x = 30, z = 52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = -30, z = 52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = 30, z = -52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = -30, z = -52, heading = 90 }, - { type = "Hesco_wallperimeter_1", x = 45, z = 40, heading = 45 }, - { type = "Hesco_wallperimeter_1", x = -45, z = 40, heading = 315 }, - { type = "Hesco_wallperimeter_1", x = 45, z = -40, heading = 135 }, - { type = "Hesco_wallperimeter_1", x = -45, z = -40, heading = 225 }, - { type = "FARP Tent", x = 52, z = 0, heading = 270 }, - { type = "FARP Tent", x = -52, z = 0, heading = 90 }, - { type = "FARP Tent", x = 36.8, z = 36.8, heading = 225 }, - { type = "container_20ft", x = -30, z = -38, heading = 45 }, - { type = "container_20ft", x = -35, z = -33, heading = 45 }, - { type = "container_20ft", x = -25, z = -43, heading = 45 }, - { type = "container_20ft", x = -33, z = -45, heading = 135 }, - { type = "GeneratorF", x = 43.1, z = -31.4, heading = 225 }, - { type = "UAZ-469", x = 12, z = 48, heading = 200 }, - { type = "UAZ-469", x = 16, z = 50, heading = 170 }, - { type = "Ural-375", x = -25, z = -35, heading = 45 }, - { type = "Landmine pot", x = 50, z = 15, heading = 0 }, - { type = "Landmine pot", x = -50, z = 15, heading = 0 }, - { type = "Landmine pot", x = 50, z = -15, heading = 0 }, - { type = "Landmine pot", x = -50, z = -15, heading = 0 }, - { type = "Landmine pot", x = 15, z = 50, heading = 0 }, - { type = "Landmine pot", x = -15, z = 50, heading = 0 }, - { type = "Landmine pot", x = 40, z = 30, heading = 0 }, - { type = "Landmine pot", x = -40, z = 30, heading = 0 }, - { type = "Landmine pot", x = 30, z = -40, heading = 0 }, - { type = "Landmine pot", x = -30, z = -40, heading = 0 }, - { type = "Landmine pot", x = 45, z = 25, heading = 0 }, - { type = "Landmine pot", x = -45, z = 25, heading = 0 }, - { type = "billboard_motorized rifle troops", x = 0, z = -58, heading = 0 }, - { type = "Sandbox", x = 38, z = -8, heading = 0 }, - { type = "Sandbox", x = -38, z = -8, heading = 0 }, - { type = "Sandbox", x = 38, z = 8, heading = 0 }, - { type = "Sandbox", x = -38, z = 8, heading = 0 }, - { type = "Black_Tyre", x = 20, z = -48, heading = 0 }, - { type = "Black_Tyre", x = -20, z = -48, heading = 0 }, - { type = "Black_Tyre", x = 24, z = -46, heading = 0 }, - { type = "Black_Tyre", x = -24, z = -46, heading = 0 }, - { type = "Black_Tyre", x = 28, z = -44, heading = 0 }, - { type = "Black_Tyre", x = -28, z = -44, heading = 0 }, - { type = "Black_Tyre", x = 48, z = 20, heading = 0 }, - { type = "Black_Tyre", x = -48, z = 20, heading = 0 }, - { type = "WatchTower", x = -38.9, z = 38.9, heading = 225 }, - { type = "warning_board_c", x = 0, z = 48, heading = 180 }, - { type = "warning_board_c", x = 48, z = 0, heading = 270 }, - { type = "warning_board_c", x = -48, z = 0, heading = 90 }, - { type = "warning_board_c", x = 35, z = -35, heading = 45 }, - { type = "warning_board_c", x = -35, z = -35, heading = 315 }, - { type = "warning_board_c", x = 35, z = 35, heading = 225 }, - }, - }, -} - --- Immersive Hover Coach configuration (messages, thresholds, throttling) --- All user-facing text lives here; logic only fills placeholders. -CTLD.HoverCoachConfig = { - enabled = true, -- master switch for hover coaching feature - coachOnByDefault = true, -- per-player default; players can toggle via F10 > Navigation > Hover Coach - - -- Pickup parameters - maxCratesPerLoad = 6, -- maximum crates the aircraft can carry simultaneously - autoPickupDistance = 25, -- meters max search distance for candidate crates - - thresholds = { - arrivalDist = 1000, -- m: start guidance "You're close…" - closeDist = 100, -- m: reduce speed / set AGL guidance - precisionDist = 8, -- m: start precision hints - captureHoriz = 10, -- m: horizontal sweet spot radius - captureVert = 10, -- m: vertical sweet spot tolerance around AGL window - aglMin = 5, -- m: hover window min AGL - aglMax = 20, -- m: hover window max AGL - maxGS = 8/3.6, -- m/s: 8 km/h for precision, used for errors - captureGS = 4/3.6, -- m/s: 4 km/h capture requirement - maxVS = 2.0, -- m/s: absolute vertical speed during capture - driftResetDist = 13, -- m: if beyond, reset precision phase - stabilityHold = 2.0 -- s: hold steady before loading - }, - - throttle = { - coachUpdate = 3.0, -- s between hint updates in precision - generic = 3.0, -- s between non-coach messages - repeatSame = 6.0 -- s before repeating same message key - }, -} - --- ========================= --- Ground Auto-Load Configuration --- ========================= --- Automatic crate loading while landed (for pilots who prefer not to hover) -CTLD.GroundAutoLoadConfig = { - Enabled = true, -- master switch for ground auto-load feature - LoadDelay = 25, -- seconds to hold position on ground before auto-loading - GroundContactAGL = 3.5, -- meters AGL considered "on the ground" (matches MEDEVAC) - MaxGroundSpeed = 2.0, -- m/s maximum ground speed during loading (~4 knots) - SearchRadius = 35, -- meters to search for nearby crates - AbortGrace = 2, -- seconds of movement/liftoff tolerated before aborting - RequirePickupZone = true, -- MUST be inside a pickup zone to auto-load (prevents drop/re-pickup loops) - AllowInFOBZones = true, -- also allow auto-load in FOB zones (once built) -} - -CTLD.GroundLoadComms = { - ProgressInterval = 5, - Start = { - "Loadmaster: Copy {count} crate(s). Give us {seconds}s to round up the rollers.", - "Ramp boss says {seconds}s and L. Jenkins will have those {count} crate(s) chained down.", - "Crew chief: {count} crate(s) inbound—stay planted for {seconds}s.", - "Forklift mafia deputized Leroy to kidnap {count} crate(s) in {seconds}s.", - "Ground crew brewing a plan: {count} crate(s) in {seconds}s.", - "Cargo gnomes awake—{seconds}s to wrangle {count} crate(s).", - "Engineers counting {count} crate(s); set a timer for {seconds}s.", - "Deck boss: {seconds}s of zen before {count} crate(s) clack aboard.", - "Log cell crunches numbers: {count} crate(s) move in {seconds}s.", - "Supply sergeant wants {seconds}s to line up {count} crate(s).", - "Palettes staged—{count} crate(s) climbing aboard after {seconds}s.", - "Ramp trolls wave: {seconds}s pause, {count} crate(s) prize.", - "Hook teams prepping—{count} crate(s) latched in {seconds}s.", - "Handler: keep rotors calm for {seconds}s; {count} crate(s) en route.", - "Deck boss hums while Jenkins L salts {count} crate(s) in {seconds}s.", - "Logistics whisperer: {seconds}s to sweet-talk {count} crate(s).", - "Crate wrangler: {count} boxen saddled after {seconds}s.", - "Crew phones mom: {count} crate(s) adopt you in {seconds}s.", - "Load team stretching—{seconds}s till {count} crate(s) leap aboard.", - "Forklift rave: {count} crate(s) crowd-surfing in {seconds}s.", - "Ammo guys promise {seconds}s to leash {count} crate(s).", - "Supply goblins: {count} crate(s) conjured after {seconds}s.", - "Winch crew rolling cables—{seconds}s countdown for {count} crate(s).", - "Deck judge bangs gavel: {count} crate(s) filed in {seconds}s.", - "Ramp DJ cues track—{seconds}s jam for {count} crate(s).", - "Hangar rats: {count} crate(s) tango aboard in {seconds}s.", - "Pit crew swapped tires; {seconds}s to fuel {count} crate(s).", - "Clipboard ninja checking boxes—{count} crate(s) ready in {seconds}s.", - "Dock boss sharpening pencils: {count} crate(s) manifest in {seconds}s.", - "Groundlings choreograph {count} crate(s) ballet—{seconds}s rehearsal.", - "Supply monks meditate {seconds}s to summon {count} crate(s).", - "Winch whisperer: {count} crate(s) ascend in {seconds}s.", - "Ramp champion bets {seconds}s for {count} crate(s).", - "Crew lounge evacuated; {count} crate(s) arriving in {seconds}s.", - "Load shack scoreboard: {seconds}s to snag {count} crate(s).", - "Logi wizard scribbles runes—{count} crate(s) appear after {seconds}s.", - "Deck sergeant orders {seconds}s freeze; {count} crate(s) inbound.", - "Cargo penguins waddling—{seconds}s to herd {count} crate(s).", - "Hangar bard plays; {count} crate(s) drop beat in {seconds}s.", - "Ramp dragon yawns: {seconds}s before {count} crate(s) charred.", - "Supply pirates shout—{count} crate(s) plundered in {seconds}s.", - "Ground crew printing receipts—{seconds}s to notarize {count} crate(s).", - "Forklift derby lights up; {count} crate(s) cross line in {seconds}s.", - "Load ninja breathes—{seconds}s later {count} crate(s) vanish aboard.", - "Deck boss bribes gravity; {count} crate(s) float up in {seconds}s.", - "Ammo elves: {seconds}s swirl to gift-wrap {count} crate(s).", - "Ramp philosopher ponders {count} crate(s) for {seconds}s.", - "Crew chef prepping snacks; {count} crate(s) served in {seconds}s.", - "Crate wrangler ties boots—{seconds}s to rope {count} crate(s).", - "Paladin of pallets: {count} crate(s) blessed in {seconds}s.", - "Load doc scribbles—{seconds}s to sign {count} crate(s).", - "Ground squirrels stash {count} crate(s) after {seconds}s.", - "Deck poet recites; {count} crate(s) respond in {seconds}s.", - "Winch gremlin oils gears—{seconds}s for {count} crate(s).", - "Ramp hype crew chants {seconds}s mantra for {count} crate(s).", - "Supply DJ scratching—{count} crate(s) drop bass in {seconds}s.", - "Cargo therapist assures {count} crate(s) in {seconds}s.", - "Crew zookeeper wrangles {count} crate(s) herd—{seconds}s.", - "Deck botanist grows {count} crate(s) vines in {seconds}s.", - "Load astronomer charts {count} crate(s) orbit—{seconds}s.", - "Ramp comedian promises laughs for {seconds}s then {count} crate(s).", - "Hangar historian says {count} crate(s) arrive in {seconds}s per tradition.", - "Supply drummer counts in {seconds}s for {count} crate(s).", - "Deck meteorologist predicts {count} crate(s) storm in {seconds}s.", - "Cargo architect sketches {count} crate(s) stacking plan—{seconds}s.", - "Ramp coder debugs manifest; {count} crate(s) compile in {seconds}s.", - "Load barista pulls espresso—{count} crate(s) perk up in {seconds}s.", - "Ground bard writes sea shanty; {count} crate(s) join in {seconds}s.", - "Supply prankster hides {count} crate(s); reveal in {seconds}s.", - "Ramp alchemist mixes fuel—{count} crate(s) transmute after {seconds}s.", - "Crate whisperer says hold {seconds}s for {count} crate(s).", - "Deck detective tracks {count} crate(s) trail—{seconds}s ETA.", - "Load beekeeper herds {count} crate(s) swarm—{seconds}s.", - "Ground tailor stitches nets; {count} crate(s) fitted in {seconds}s.", - "Supply DJ rewinds—{seconds}s then {count} crate(s) drop.", - "Ramp cloud-gazer sees {count} crate(s) in {seconds}s forecast.", - "Load punster drafts {count} crate(s) jokes—{seconds}s needed.", - "Deck volcanologist warns {count} crate(s) eruption in {seconds}s.", - "Cargo puppeteer choreographs {count} crate(s); show in {seconds}s.", - "Ground cartographer maps {count} crate(s) journey—{seconds}s.", - "Ramp mathematician solves {count} crate(s) problem—{seconds}s.", - "Load meteor chaser counts {seconds}s till {count} crate(s) strike.", - "Supply astronomer spots {count} crate(s) constellation—{seconds}s.", - "Deck lifeguard whistles; {count} crate(s) swim aboard in {seconds}s.", - "Cargo sommelier decants {count} crate(s)—need {seconds}s to breathe.", - "Ramp locksmith picks {count} crate(s) locks—{seconds}s.", - "Load carpenter measures twice; {count} crate(s) cut loose in {seconds}s.", - "Ground geologist drills plan—{count} crate(s) surface in {seconds}s.", - "Supply fireman slides pole; {count} crate(s) rescued in {seconds}s.", - "Ramp hacker breaches {count} crate(s) firewall—{seconds}s.", - "Load illusionist shuffles {count} crate(s)—{seconds}s reveal.", - "Deck astronomer winks: {count} crate(s) align in {seconds}s.", - "Cargo mixologist shakes {count} crate(s); {seconds}s pour time.", - "Ramp weatherman says {count} crate(s) drizzle in {seconds}s.", - "Load surveyor levels deck—{count} crate(s) land in {seconds}s.", - "Ground sculptor chisels path; {count} crate(s) glide in {seconds}s.", - "Supply DJ double-drops; {count} crate(s) drop after {seconds}s.", - "Deck clockmaker rewinds {seconds}s, {count} crate(s) tick in.", - "Cargo cart racer drifts up with {count} crate(s) in {seconds}s.", - "Ramp ringmaster cues circus; {count} crate(s) center ring in {seconds}s.", - }, - Progress = { - "Crew chief: {remaining}s on the clock—don't wiggle.", - "Ramp boss tapping boot while Jenkins L calls {remaining}s remaining.", - "Leroy J. humming; {remaining}s until hooks click.", - "Forklift tires chirp—{remaining}s before stack settles.", - "Cargo chains rattling, {remaining}s left.", - "Handler flashes thumbs-up once Leroy yells go in {remaining}s.", - "Deck boss juggling paperwork—{remaining}s.", - "Ground crew sipping caf, {remaining}s reminder.", - "Winch motors whining: {remaining}s.", - "Clipboard ninja says {remaining}s until signatures.", - "Ramp trolls mid chant—{remaining}s.", - "Load doc verifying straps—{remaining}s.", - "Forklift derby lap {remaining}s.", - "Ammo goblins grumble {remaining}s.", - "Supply bard hits chorus in {remaining}s.", - "Deck meteorologist reads {remaining}s forecast.", - "Cargo AI recalculating—{remaining}s.", - "Ground gremlin twisting wrenches for {remaining}s.", - "Ramp conductor waves baton—{remaining}s of tempo.", - "Load punster drafting joke; {remaining}s left.", - "Deck alchemist stirring {remaining}s.", - "Crate wrangler double-knots—{remaining}s.", - "Forklift jazz solo ends in {remaining}s.", - "Supply DJ building drop—{remaining}s.", - "Ramp philosopher meditates {remaining}s.", - "Load botanist waters nets—{remaining}s.", - "Deck coder compiling manifest—{remaining}s.", - "Ground astronomer counts {remaining}s shooting stars.", - "Cargo weathervane spins {remaining}s.", - "Loader handshake pending {remaining}s.", - "Ramp poet editing stanza—{remaining}s.", - "Load bartender shakes drink—{remaining}s of chill.", - "Deck zookeeper calms crates—{remaining}s.", - "Ground sculptor chisels wedge—{remaining}s.", - "Supply tailor hemming slings—{remaining}s.", - "Ramp locksmith turning tumblers—{remaining}s.", - "Load whisperer soothing pallets for {remaining}s.", - "Deck drummer counting down {remaining}s.", - "Cargo painter adds racing stripes—{remaining}s.", - "Ground beekeeper herding boxes—{remaining}s.", - "Ramp ninja mid flip—{remaining}s.", - "Load DJ scratching vinyl for {remaining}s.", - "Deck geologist sampling dust—{remaining}s.", - "Cargo puppeteer angles strings—{remaining}s.", - "Ground interpreter translating crate beeps—{remaining}s.", - "Ramp comedian holding punchline {remaining}s.", - "Load surfer riding forklift forks—{remaining}s.", - "Deck data nerd buffering {remaining}s.", - "Cargo snowplow clearing pebbles—{remaining}s.", - "Ground pyrotechnician keeps sparks at bay {remaining}s.", - "Ramp snorkeler holding breath {remaining}s.", - "Load astronomer calibrates scope—{remaining}s.", - "Deck spelunker exploring skid row—{remaining}s.", - "Cargo beekeeper suits up—{remaining}s.", - "Ground carpenter setting chalk lines—{remaining}s.", - "Ramp wizard muttering {remaining}s spell.", - "Load falconer whistles—{remaining}s before talons release.", - "Deck tuba blares sustain for {remaining}s.", - "Cargo juggler keeps crates aloft {remaining}s.", - "Ground chemist titrates patience—{remaining}s.", - "Ramp puppeteer cues strings—{remaining}s.", - "Load archaeologist brushes dust—{remaining}s.", - "Deck racer redlines stopwatch {remaining}s.", - "Cargo coder spams F5 for {remaining}s.", - "Ground gardener trims net corners—{remaining}s.", - "Ramp DJ layering loops—{remaining}s.", - "Load glaciologist monitors ice melt—{remaining}s.", - "Deck prankster hides cones for {remaining}s.", - "Cargo scribe inks manifest—{remaining}s.", - "Ground wanderer paces {remaining}s.", - "Ramp baker timing souffle—{remaining}s.", - "Load therapist tells crates to breathe {remaining}s.", - "Deck hype squad chanting {remaining}s.", - "Cargo spelunker checks tie-down caverns—{remaining}s.", - "Ground sherpa hauls straps—{remaining}s.", - "Ramp rebel flicks toothpick {remaining}s.", - "Load fortune teller sees {remaining}s in cards.", - "Deck quatermaster double-counts {remaining}s.", - "Cargo dinosaur roaring softly {remaining}s.", - "Ground mech tunes hydraulics—{remaining}s.", - "Ramp botanist sniffs fuel—{remaining}s.", - "Load journalist scribbles {remaining}s update.", - "Deck gamer farming XP for {remaining}s.", - "Cargo archivist files forms—{remaining}s.", - "Ground referee watches chalk line—{remaining}s.", - "Ramp kite flyer reels string—{remaining}s.", - "Load detective dusts prints—{remaining}s.", - "Deck prank caller rings tower for {remaining}s.", - "Cargo shoemaker taps soles—{remaining}s.", - "Ground chandler pours wax—{remaining}s.", - "Ramp falcon loops {remaining}s.", - "Load diver equalizes ears {remaining}s.", - "Deck astronomer rechecks alignment—{remaining}s.", - "Cargo DJ rewinds sample {remaining}s.", - "Ground quartermaster ties ledger—{remaining}s.", - "Ramp magpie collecting shiny bolts—{remaining}s.", - "Load muralist adds stencil—{remaining}s.", - "Deck tech updates firmware—{remaining}s.", - "Cargo babysitter hushes pallets {remaining}s.", - "Ground marshal gives steady-hand signal for {remaining}s.", - "Hey! Watch what you're doing with those crates! {remaining}s left.", - "Hey! You can't put that there! Over there instead! {remaining}s left.", - "Hey! Is your name Leroy! Because you better be getting those crates loaded! {remaining}s left.", - "Jenkins!!! Get those crates loaded! {remaining}s left.", - "Leroy!!! Stop daydreaming and get those crates loaded! {remaining}s left.", - "Come on Leroy, those crates won't load themselves! {remaining}s left.", - "Hurry up Jenkins, we got a bird waiting! {remaining}s left.", - "Get a move on Leroy, those crates ain't gonna load themselves! {remaining}s left.", - "You call that loading Jenkins? {remaining}s left.", - "Pick up the pace Leroy! {remaining}s left.", - "Faster Jenkins! {remaining}s left.", - "Let's hustle Leroy! {remaining}s left.", - "Time's a-tickin' Jenkins! {remaining}s left.", - "Chop-chop Leroy! {remaining}s left.", - "Jenkins! Stop playing with the cargo! Those dildo's belong to Mo! {remaining}s left.", - "Leroy here sir! We got that cargo right where you wanted it! {remaining}s left.", - "Jenkins! Get back to work! Mo's complaining! {remaining}s left.", - }, - Complete = { - "Crew chief: {count} crate(s) strapped and smiling—clear to lift.", - "Ramp boss reports {count} crate(s) locked tight by Jenkins L.", - "Loadmaster: {count} crate(s) tucked in like kittens.", - "Forklift mafia salutes—{count} crate(s) delivered.", - "Deck boss stamped {count} crate(s) good to go.", - "Cargo goblins vanished—Leroy swears he secured {count} crate(s).", - "Winch team claims victory—{count} crate(s) aboard.", - "Clipboard ninja checked {count} boxes.", - "Ramp trolls cheer: {count} crate(s) riding shotgun.", - "Handler: {count} crate(s) bolted; throttle up.", - "Deck poet pens ode to {count} crate(s) now yours.", - "Supply gnomes wave bye to {count} crate(s).", - "Ground crew says {count} crate(s) ready for adventure.", - "Ramp DJ drops beat—{count} crate(s) locked in rhythm.", - "Load doc signs release: {count} crate(s).", - "Deck alchemist transmuted paperwork—{count} crate(s).", - "Cargo therapist declares {count} crate(s) emotionally stable.", - "Forklift derby trophy goes to {count} crate(s) now aboard.", - "Ramp philosopher satisfied—{count} crate(s) exist on deck.", - "Load botanist pruned nets—{count} crate(s) bloom there.", - "Deck coder returned true: {count} crate(s).", - "Ground bard ends tune with {count} crate(s) crescendo.", - "Ramp prankster can't hide {count} crate(s)—they're on board.", - "Cargo beekeeper counts {count} crate(s) in hive.", - "Deck meteorologist confirms {count} crate(s) high pressure.", - "Load painter signs mural of {count} crate(s) strapped.", - "Ground tailor hemmed slings—{count} crate(s) fitted.", - "Ramp locksmith snapped padlocks on {count} crate(s).", - "Cargo DJ fades track—{count} crate(s) secure.", - "Deck archaeologist labels {count} crate(s) artifact.", - "Load comedian retires bit; {count} crate(s) landing.", - "Ground sculptor polishes {count} crate(s) corners.", - "Ramp astronomer charts {count} crate(s) orbit now stable.", - "Cargo puppeteer bows—{count} crate(s) performance done.", - "Deck detective solved case of {count} crate(s).", - "Load shark fins down: {count} crate(s) fed to cargo bay.", - "Ground sherpa drops pack—{count} crate(s) summit achieved.", - "Ramp hacker logs off—{count} crate(s) uploaded.", - "Cargo barista served {count} crate(s) double-shot of tie downs.", - "Deck referee whistles end—{count} crate(s) win.", - "Load fencer sheathes sword—{count} crate(s) defended.", - "Ground medic clears {count} crate(s) to travel.", - "Ramp monk bows: {count} crate(s) enlightened.", - "Cargo kite now tethered—{count} crate(s).", - "Deck astronomer applauds {count} crate(s) alignment.", - "Load geologist marks {count} crate(s) strata complete.", - "Ground gardener plants {count} crate(s) firmly.", - "Ramp puppet master cuts strings—{count} crate(s) stay.", - "Cargo DJ signs off—{count} crate(s) final mix.", - "Deck beekeeper seals hive with {count} crate(s).", - "Load mathematician tallies {count} crate(s) exact.", - "Ground fireworks canceled—{count} crate(s) safe.", - "Ramp storm chaser says {count} crate(s) in the eye.", - "Cargo sculptor chisels notch—{count} crate(s) nested.", - "Deck tailor stitches last knot on {count} crate(s).", - "Load wizard snaps fingers—{count} crate(s) appear strapped.", - "Ground referee raises flag: {count} crate(s) legal.", - "Ramp brewer clinks mugs—{count} crate(s) on tap.", - "Cargo philosopher logs {count} crate(s) as truth.", - "Deck DJ loops outro—{count} crate(s) seated.", - "Load detective closes file—{count} crate(s) accounted.", - "Ground lifeguard thumbs up—{count} crate(s) afloat.", - "Ramp spelunker resurfaces with {count} crate(s).", - "Cargo dancer nails finale—{count} crate(s).", - "Deck poet rhymes {count} crate(s) with fate.", - "Load dragon goes back to sleep—{count} crate(s) fed.", - "Ground chemist labels vials—{count} crate(s) stable.", - "Ramp tailor satisfied stitchwork on {count} crate(s).", - "Cargo quarterback yells touchdown—{count} crate(s).", - "Deck glaciologist notes {count} crate(s) frozen in place.", - "Load eagle roosts—{count} crate(s) in nest.", - "Ground DJ cues victory sting—{count} crate(s) done.", - "Ramp botanist logs {count} crate(s) in flora guide.", - "Cargo surfer throws shaka—{count} crate(s) ride smooth.", - "Deck juggler bows—{count} crate(s) landed.", - "Load translator confirms {count} crate(s) say thanks.", - "Ground weatherman clears skies—{count} crate(s) shining.", - "Ramp trickster hides clipboard: {count} crate(s) can't hide.", - "Cargo archivist files {count} crate(s) under awesome.", - "Deck beekeeper high-fives {count} crate(s) bees.", - "Load marathoner crosses finish with {count} crate(s).", - "Ground astronomer stamps {count} crate(s) star chart.", - "Ramp baker presents {count} crate(s) pie fresh.", - "Cargo diver surfaces cheering {count} crate(s).", - "Deck data nerd graphs {count} crate(s) success.", - "Load hypnotist snaps fingers—{count} crate(s) obey.", - "Ground marshal rolls wand—{count} crate(s) staged.", - "Ramp timekeeper stops watch at {count} crate(s).", - "Cargo composer final chord—{count} crate(s).", - "Deck quartermaster locks ledger: {count} crate(s).", - "Load astronaut gives thumbs up from cargo bay—{count} crate(s).", - "Ground ninja vanishes leaving {count} crate(s).", - "Ramp botanist labels {count} crate(s) species secure.", - "Cargo conductor yells all aboard—{count} crate(s).", - "Deck muralist signs name under {count} crate(s).", - "Load shepherd counts {count} crate(s) asleep.", - "Ground pirate buries hatchet—{count} crate(s) share plunder.", - "Ramp gamer hits save—{count} crate(s) progress locked.", - "Cargo weaver ties final knot on {count} crate(s).", - "Deck captain stamps log—{count} crate(s) embarked.", - } -} - --- ========================= --- MEDEVAC Configuration --- ========================= --- #region MEDEVAC Config -CTLD.MEDEVAC = { - Enabled = true, - - -- Crew spawning - -- Per-coalition spawn probabilities for asymmetric scenarios - CrewSurvivalChance = { - [coalition.side.BLUE] = .50, -- probability (0.0-1.0) that BLUE crew survives to spawn MEDEVAC request. 1.0 = 100% (testing), 0.02 = 2% (production) - [coalition.side.RED] = .50, -- probability (0.0-1.0) that RED crew survives to spawn MEDEVAC request - }, - ManPadSpawnChance = { - [coalition.side.BLUE] = 0.1, -- probability (0.0-1.0) that BLUE crew spawns with a MANPADS soldier. 1.0 = 100% (testing), 0.1 = 10% (production) - [coalition.side.RED] = 0.1, -- probability (0.0-1.0) that RED crew spawns with a MANPADS soldier - }, - CrewSpawnDelay = 300, -- seconds after death before crew spawns (gives battle time to clear). 300 = 5 minutes - CrewAnnouncementDelay = 60, -- seconds after spawn before announcing mission to players (verify crew survival). 60 = 1 minute - CrewTimeout = 3600, -- 1 hour max wait before crew is KIA (after spawning) - CrewSpawnOffset = 25, -- meters from death location (toward nearest enemy) - CrewDefaultSize = 2, -- default crew size if not specified in catalog - CrewDefendSelf = true, -- crews will return fire if engaged - - -- Crew protection during announcement delay - CrewImmortalDuringDelay = true, -- make crew immortal (invulnerable) during announcement delay to prevent early death - CrewInvisibleDuringDelay = true, -- make crew invisible to AI during announcement delay (won't be targeted by enemy) - CrewImmortalAfterAnnounce = false, -- if true, crew stays immortal even after announcing mission (easier gameplay) - KeepCrewInvisibleForLifetime = true, -- if true, keep crew invisible to AI for entire mission lifetime - - -- Smoke signals - PopSmokeOnSpawn = true, -- crew pops smoke when they first spawn - PopSmokeOnApproach = true, -- crew pops smoke when rescue helo approaches - PopSmokeOnApproachDistance = 8000, -- meters - distance at which crew detects approaching helo - SmokeCooldown = 900, -- seconds between smoke pops (default 900 = 15 minutes) - prevents spam when helo circles - SmokeColor = { -- smoke colors per coalition - [coalition.side.BLUE] = trigger.smokeColor.Blue, - [coalition.side.RED] = trigger.smokeColor.Red, - }, - SmokeOffsetMeters = 0, -- horizontal offset from crew position (meters) so helicopters don't hover in smoke - SmokeOffsetRandom = true, -- randomize horizontal offset direction (true) or always offset north (false) - SmokeOffsetVertical = 20, -- vertical offset above ground level (meters) for better visibility - - -- Greeting messages when crew detects rescue helo - GreetingMessages = { - "Stranded Crew: We see you, boy that thing is loud! Follow the smoke!", - "Stranded Crew: We hear you coming.. yep, we see you.. bring it on down to the smoke!", - "Stranded Crew: Whew! We sure are glad you're here! Over here by the smoke!", - "Stranded Crew: About damn time! We're over here at the smoke!", - "Stranded Crew: Thank God! We thought you forgot about us! Follow the smoke!", - "Stranded Crew: Hey! We're the good looking ones by the smoke!", - "Stranded Crew: Copy that, we have visual! Popping smoke now!", - "Stranded Crew: Roger, we hear your rotors! Follow the smoke and come get us!", - "Stranded Crew: Finally! My feet are killing me out here! We're at the smoke!", - "Stranded Crew: That's the prettiest sound we've heard all day! Head for the smoke!", - "Stranded Crew: Is that you or are the enemy reinforcements? Just kidding, get down here at the smoke!", - "Stranded Crew: We've been working on our tans, come check it out! Smoke's popped!", - "Stranded Crew: Hope you brought snacks, we're starving! Follow the smoke in!", - "Stranded Crew: Your Uber has arrived? No, YOU'RE our Uber! We're at the smoke!", - "Stranded Crew: Could you be any louder? The whole country knows we're here now! At least follow the smoke!", - "Stranded Crew: Next time, could you not take so long? My coffee got cold! Smoke's up!", - "Stranded Crew: We see you! Don't worry, we only look this bad! Head for the smoke!", - "Stranded Crew: Inbound helo spotted! Someone owes me 20 bucks! Smoke is marking our position!", - "Stranded Crew: Hey taxi! We're at the corner of Blown Up Avenue and Oh Crap Street! Follow the smoke!", - "Stranded Crew: You're a sight for sore eyes! Literally, there's so much dust out here! Smoke's popped!", - "Stranded Crew: Visual contact confirmed! Get your ass down here to the smoke!", - "Stranded Crew: Oh thank hell, a bird! We're ready to get the fuck outta here! Smoke's marking us!", - "Stranded Crew: We hear you! Follow the smoke and the smell of desperation!", - "Stranded Crew: Rotors confirmed! Popping smoke now! Don't leave us hanging!", - "Stranded Crew: That you up there? About time! We've been freezing out here! Look for the smoke!", - "Stranded Crew: Helo inbound! We've got the salvage and the trauma, come get both! Smoke's up!", - "Stranded Crew: Eyes on rescue bird! Someone tell me this isn't a mirage! Follow the smoke!", - "Stranded Crew: We hear those beautiful rotors! Land this thing before we cry! Smoke marks the spot!", - "Stranded Crew: Confirmed visual! If you leave without us, we're keeping the salvage! Smoke's popped!", - "Stranded Crew: Choppers overhead! Finally! We were about to start walking! Head for the smoke!", - "Stranded Crew: That's our ride! Everyone look alive and try not to smell too bad! We're at the smoke!", - "Stranded Crew: Helo spotted! Quick, somebody look professional! Smoke's marking our position!", - "Stranded Crew: You're here! We'd hug you but we're covered in dirt and shame! Follow the smoke!", - "Stranded Crew: Bird inbound! Popping smoke! Someone owes us overtime for this shit!", - "Stranded Crew: Visual on rescue! Get down here before the enemy spots you too! Smoke's up!", - "Stranded Crew: We see you! Follow the smoke and broken dreams!", - "Stranded Crew: Incoming helo! Thank fuck! We're ready to leave this lovely hellscape! Smoke marks us!", - "Stranded Crew: Eyes on bird! We've got salvage, stories, and a desperate need for AC! Look for the smoke!", - "Stranded Crew: That you? Get down here! We've been standing here like idiots for hours! Smoke's popped!", - "Stranded Crew: Helo visual! Popping smoke! Anyone got room for some very tired, very angry crew?", - "Stranded Crew: We see you up there! Don't you dare fly past us! Follow the smoke!", - "Stranded Crew: Rescue inbound! Finally! We were starting to plan a walk home! Smoke's marking us!", - "Stranded Crew: Contact! We have eyes on you! Come get us before we change our minds about this whole military thing! Smoke's up!", - "Stranded Crew: Helo confirmed! Smoke's up! Let's get this reunion started!", - "Stranded Crew: You beautiful bastard! We see you! Get down here to the smoke!", - "Stranded Crew: Visual on rescue! We're ready! Let's get out before our luck runs out! Follow the smoke!", - "Stranded Crew: Bird spotted! Smoke deployed! Hurry before we attract more attention!", - "Stranded Crew: There you are! What took so long? Never mind, just land at the smoke!", - "Stranded Crew: We see you! Follow the smoke and the sound of relieved cursing!", - "Stranded Crew: Helo inbound! Everyone grab your shit! We're leaving this place! Smoke marks the LZ!", - "Stranded Crew: Is that our ride or just someone sightseeing? Either way, smoke's up!", - "Stranded Crew: We've got eyes on you! Come to the smoke before we lose our minds!", - "Stranded Crew: Tally ho! That's military speak for 'follow the damn smoke'!", - "Stranded Crew: You're late! But we'll forgive you if you land at the smoke!", - "Stranded Crew: Helo overhead! Popping smoke! This better not be a drill!", - "Stranded Crew: Contact confirmed! Smoke's marking us! Don't make us wait!", - "Stranded Crew: We hear rotors! Please be friendly! Smoke's up either way!", - "Stranded Crew: Bird inbound! Smoke deployed! Let's make this quick!", - "Stranded Crew: Visual on helo! Follow the smoke to the worst day of our lives!", - "Stranded Crew: You found us! Smoke's marking the spot! Gold star for you!", - "Stranded Crew: Rescue bird spotted! Smoke's up! We're the desperate ones!", - "Stranded Crew: We see you! Land at the smoke before we start charging rent!", - "Stranded Crew: Helo visual! Smoke deployed! This isn't a vacation spot!", - "Stranded Crew: That's you! Finally! Follow the smoke to glory!", - "Stranded Crew: Eyes on rescue! Smoke marks our misery! Come fix it!", - "Stranded Crew: We hear you! Smoke's popped! Let's end this nightmare!", - "Stranded Crew: Contact! Visual! Smoke! All the good stuff! Get down here!", - "Stranded Crew: Rescue inbound! Smoke's up! We've rehearsed this moment!", - "Stranded Crew: You're here! Smoke's marking us! Don't screw this up!", - "Stranded Crew: Helo confirmed! Follow the smoke to the saddest party ever!", - "Stranded Crew: We see you! Smoke's deployed! Land before we cry!", - "Stranded Crew: Bird spotted! Smoke marks us! We're the ones waving frantically!", - "Stranded Crew: Visual contact! Smoke's up! This is not a joke!", - "Stranded Crew: You made it! Follow the smoke! We've got beer money! (Lies, but follow the smoke anyway!)", - "Stranded Crew: Helo inbound! Smoke deployed! Pick us up before our wives find out!", - "Stranded Crew: We hear you! Smoke's marking our stupidity! Come save us from ourselves!", - "Stranded Crew: Contact! Smoke's up! We promise we're worth the fuel!", - "Stranded Crew: Rescue bird! Smoke marks the spot! This is awkward for everyone!", - "Stranded Crew: You're here! Smoke deployed! We'll explain everything later!", - "Stranded Crew: Visual on helo! Follow the smoke to disappointment and gratitude!", - "Stranded Crew: We see you! Smoke's up! Let's never speak of this again!", - "Stranded Crew: Helo spotted! Smoke marks us! We're the embarrassed ones!", - "Stranded Crew: Contact confirmed! Smoke deployed! This wasn't in the manual!", - "Stranded Crew: You found us! Smoke's up! Someone's getting a promotion!", - "Stranded Crew: Bird inbound! Follow the smoke to heroes and idiots!", - "Stranded Crew: We hear you! Smoke's marking us! Please don't tell command!", - "Stranded Crew: Visual! Smoke deployed! We'll buy you drinks forever!", - "Stranded Crew: Helo confirmed! Smoke's up! Best day of our lives right here!", - "Stranded Crew: You're here! Follow the smoke! We're never leaving base again!", - "Stranded Crew: Contact! Smoke marks us! This is our rock bottom!", - "Stranded Crew: Rescue inbound! Smoke deployed! We're upgrading your Yelp review!", - "Stranded Crew: We see you! Smoke's up! Land before the enemy does!", - "Stranded Crew: Bird spotted! Follow the smoke! We've learned our lesson!", - "Stranded Crew: Visual on helo! Smoke's marking us! This is so embarrassing!", - "Stranded Crew: You made it! Smoke deployed! We owe you everything!", - "Stranded Crew: Helo inbound! Smoke marks the spot! Let's go home!", - "Stranded Crew: We hear you! Follow the smoke! We're the lucky ones!", - "Stranded Crew: Contact confirmed! Smoke's up! Thank you, thank you, thank you!", - "Stranded Crew: Rescue bird! Smoke deployed! You're our favorite person ever!", - }, - - -- Request airlift messages (initial mission announcement) - RequestAirLiftMessages = { - "Stranded Crew: This is {vehicle} crew at {grid}. Need pickup ASAP! We have {salvage} salvage to collect.", - "Stranded Crew: Yo, this is {vehicle} survivors at {grid}. Come get us before the bad guys do! {salvage} salvage available.", - "Stranded Crew: {vehicle} crew reporting from {grid}. We're alive but our ride isn't. {salvage} salvage ready for extraction.", - "Stranded Crew: Mayday! {vehicle} crew at {grid}. Send taxi, will pay in salvage! ({salvage} units available)", - "Stranded Crew: This is what's left of {vehicle} crew at {grid}. Pick us up and grab the {salvage} salvage while you're at it!", - "Stranded Crew: {vehicle} survivors here at {grid}. We've got {salvage} salvage and a bad attitude. Come get us!", - "Stranded Crew: Former {vehicle} operators at {grid}. Vehicle's toast but we salvaged {salvage} units. Need immediate evac!", - "Stranded Crew: {vehicle} crew broadcasting from {grid}. Situation: homeless. Salvage: {salvage} units. Mood: not great.", - "Stranded Crew: This is {vehicle} at {grid}. Well, WAS {vehicle}. Now it's scrap. Got {salvage} salvage though!", - "Stranded Crew: Hey! {vehicle} crew at {grid}! Our insurance definitely doesn't cover this. {salvage} salvage available.", - "Stranded Crew: {vehicle} survivors reporting. Grid {grid}. Status: walking. Salvage: {salvage}. Pride: wounded.", - "Stranded Crew: To whom it may concern: {vehicle} crew at {grid} requests immediate pickup. {salvage} salvage awaiting recovery.", - "Stranded Crew: {vehicle} down at {grid}. Crew status: annoyed but alive. Salvage count: {salvage}. Hurry up!", - "Stranded Crew: This is a priority call from {vehicle} crew at {grid}. We got {salvage} salvage and zero patience left!", - "Stranded Crew: {vehicle} operators at {grid}. The vehicle gave up, we didn't. {salvage} salvage ready to go!", - "Stranded Crew: Urgent! {vehicle} crew stranded at {grid}. Got {salvage} salvage and a serious need for extraction!", - "Stranded Crew: {vehicle} here, well, parts of it anyway. Crew at {grid}. Salvage: {salvage}. Morale: questionable.", - "Stranded Crew: {vehicle} down at {grid}. We're fine, vehicle's dead. {salvage} salvage secured. Come get us before we walk home!", - "Stranded Crew: Calling all angels! {vehicle} crew at {grid} needs a lift. Bringing {salvage} salvage as payment!", - "Stranded Crew: {vehicle} crew broadcasting from scenic {grid}. Collected {salvage} salvage. Would not recommend this location!", - "Stranded Crew: This is {vehicle} at {grid}. Vehicle status: spectacular fireball (was). Crew status: could use a ride. Salvage: {salvage}.", - "Stranded Crew: {vehicle} survivors at {grid}. We've got {salvage} salvage and stories you won't believe. Extract us!", - "Stranded Crew: Former {vehicle} crew at {grid}. Current occupants of a smoking crater. {salvage} salvage available!", - "Stranded Crew: {vehicle} operators requesting immediate evac from {grid}. Salvage secured: {salvage} units. Bring beer.", - "Stranded Crew: This is {vehicle} crew. Location: {grid}. Situation: not ideal. Salvage: {salvage}. Need: helicopter. NOW.", - "Stranded Crew: {grid}, party of {crew_size} from {vehicle}. Got {salvage} salvage and nowhere to go. Send help!", - "Stranded Crew: {vehicle} down at {grid}. Crew bailed, grabbed {salvage} salvage, now standing here like idiots. Pick us up!", - "Stranded Crew: Emergency broadcast from {vehicle} crew at {grid}. {salvage} salvage ready. Our ride? Not so much.", - "Stranded Crew: {vehicle} at {grid}. Status report: vehicle's a loss, crew's intact, {salvage} salvage secured. Send taxi!", - "Stranded Crew: Hey command! {vehicle} crew at {grid}. We saved {salvage} salvage but couldn't save the vehicle. Priorities!", - "Stranded Crew: This is {vehicle} broadcasting from {grid}. Crew's good, vehicle's bad, {salvage} salvage available. Get us outta here!", - "Stranded Crew: {vehicle} survivors at {grid} with {salvage} salvage. We're sunburned, pissed off, and ready for extraction!", - "Stranded Crew: Attention: {vehicle} crew at {grid} requires pickup. {salvage} salvage recovered. Hurry before we become salvage too!", - "Stranded Crew: {vehicle} here at {grid}. The good news: {salvage} salvage. The bad news: everything else. Send help!", - "Stranded Crew: From the smoking remains of {vehicle} at {grid}, we bring you {salvage} salvage and a request for immediate evac!", - "Stranded Crew: {vehicle} crew calling from {grid}. Vehicle's done, crew's done waiting. {salvage} salvage ready. Move it!", - "Stranded Crew: This is {vehicle} at {grid}. Collected {salvage} salvage while our ride went up in flames. Worth it?", - "Stranded Crew: {vehicle} operators at {grid}. Got {salvage} salvage and a newfound appreciation for walking. Please send helo!", - "Stranded Crew: {grid} here. {vehicle} crew reporting. Salvage count: {salvage}. Ride count: zero. Help count: needed!", - "Stranded Crew: {vehicle} down at {grid}! Crew up and ready with {salvage} salvage! Someone come get us already!", - "Stranded Crew: This is {vehicle} broadcasting on guard. Position {grid}. {salvage} salvage secured. Crew status: tired of your shit, send pickup!", - "Stranded Crew: {vehicle} crew at {grid} here. We've got {salvage} salvage, bad sunburns, and a dying radio battery. Hurry!", - "Stranded Crew: Emergency call from {grid}! {vehicle} crew alive with {salvage} salvage. Vehicle? Not so lucky. Extract ASAP!", - "Stranded Crew: {vehicle} at {grid}. Mission status: FUBAR. Crew status: alive. Salvage status: {salvage} units ready. Send bird!", - "Stranded Crew: This is {vehicle} crew. We're at {grid} with {salvage} salvage and zero transportation. Someone fix that!", - "Stranded Crew: {vehicle} survivors broadcasting from {grid}. Got the salvage ({salvage} units), lost the vehicle. Fair trade?", - "Stranded Crew: Urgent from {grid}! {vehicle} crew here with {salvage} salvage and rapidly depleting patience. Pick us up!", - "Stranded Crew: {vehicle} down at {grid}. Crew condition: grumpy but mobile. Salvage available: {salvage}. Ride home: none.", - "Stranded Crew: This is {vehicle} calling from {grid}. We're standing in the middle of nowhere with {salvage} salvage. Sound fun?", - "Stranded Crew: {vehicle} crew at {grid}. Salvage recovered: {salvage}. Pride recovered: maybe later. Need pickup now!", - "Stranded Crew: SOS from {grid}! {vehicle} crew requesting airlift! {salvage} salvage secured! This is not a drill!", - "Stranded Crew: {vehicle} survivors at {grid}. Vehicle kaput. Crew intact. {salvage} salvage ready. Send chopper!", - "Stranded Crew: This is {vehicle} crew at {grid}. We walked away from the wreck with {salvage} salvage. Now what?", - "Stranded Crew: Priority message! {vehicle} down at {grid}! Crew needs evac! {salvage} salvage available!", - "Stranded Crew: {vehicle} at {grid}. The vehicle didn't make it but we did. {salvage} salvage waiting. Send help!", - "Stranded Crew: Distress call from {grid}! {vehicle} crew needs immediate pickup! {salvage} salvage on site!", - "Stranded Crew: {vehicle} operators broadcasting from {grid}. Status: stranded. Payload: {salvage} salvage. Request: extraction!", - "Stranded Crew: This is {vehicle} crew. Grid: {grid}. Vehicle: destroyed. Salvage: {salvage}. Spirit: broken. Send pickup!", - "Stranded Crew: {vehicle} down at {grid}. We've got {salvage} salvage and a story that'll make you cringe. Extract us!", - "Stranded Crew: Emergency! {vehicle} crew at {grid}! Vehicle lost! {salvage} salvage recovered! Need airlift stat!", - "Stranded Crew: {vehicle} survivors reporting from {grid}. {salvage} salvage secured. Vehicle unsalvageable. We're not!", - "Stranded Crew: This is {vehicle} at {grid}. Crew escaped with {salvage} salvage. Need immediate extraction before enemy finds us!", - "Stranded Crew: {vehicle} crew broadcasting from {grid}. Got {salvage} salvage. Lost everything else. Please respond!", - "Stranded Crew: Mayday from {grid}! {vehicle} crew needs rescue! {salvage} salvage available! Don't leave us here!", - "Stranded Crew: {vehicle} operators at {grid}. Salvage count: {salvage}. Morale count: negative. Pickup count: zero so far!", - "Stranded Crew: This is {vehicle} crew at {grid}. We managed to save {salvage} salvage. Can you save us?", - "Stranded Crew: {vehicle} down at {grid}! Crew on foot with {salvage} salvage! Send taxi before we start hitchhiking!", - "Stranded Crew: Emergency call! {vehicle} crew at {grid}! {salvage} salvage ready! Vehicle not! We need help!", - "Stranded Crew: {vehicle} survivors broadcasting from {grid}. We're alive, vehicle's not, {salvage} salvage secured. What now?", - "Stranded Crew: This is {vehicle} at {grid}. Crew status: homeless. Salvage status: {salvage} units. Transportation status: needed!", - "Stranded Crew: {vehicle} crew calling from {grid}. We've got {salvage} salvage and no way home. Fix that!", - "Stranded Crew: Priority rescue needed! {vehicle} at {grid}! {salvage} salvage secured! Crew waiting!", - "Stranded Crew: {vehicle} operators from {grid}. Vehicle destroyed. Salvage recovered: {salvage}. Us recovered: not yet!", - "Stranded Crew: This is {vehicle} crew. Location: {grid}. Salvage: {salvage}. Transportation: missing. Patience: running out!", - "Stranded Crew: {vehicle} down at {grid}! We escaped with {salvage} salvage! Send extraction before our luck runs out!", - "Stranded Crew: Emergency broadcast from {grid}! {vehicle} crew needs airlift! {salvage} salvage ready for recovery!", - "Stranded Crew: {vehicle} survivors at {grid}. Got {salvage} salvage. Need helicopter. Preferably soon. Please?", - "Stranded Crew: This is {vehicle} at {grid}. Vehicle: totaled. Crew: intact. Salvage: {salvage}. Ride: requested!", - "Stranded Crew: {vehicle} crew broadcasting from {grid}. We saved {salvage} salvage from the wreck. Now save us!", - "Stranded Crew: Urgent! {vehicle} at {grid}! Crew needs extraction! {salvage} salvage available! Respond ASAP!", - "Stranded Crew: {vehicle} operators from {grid}. Salvage secured: {salvage}. Everything else: lost. Help requested!", - "Stranded Crew: This is {vehicle} crew at {grid}. {salvage} salvage recovered. Now we need to be recovered!", - "Stranded Crew: {vehicle} down at {grid}! Crew survived with {salvage} salvage! Vehicle didn't! Send pickup!", - "Stranded Crew: Emergency call from {grid}! {vehicle} crew requesting immediate evac! {salvage} salvage on hand!", - "Stranded Crew: {vehicle} survivors broadcasting from {grid}. Status: stranded. Cargo: {salvage} salvage. Mood: desperate!", - "Stranded Crew: This is {vehicle} at {grid}. We walked away from disaster with {salvage} salvage. Don't make us walk home!", - "Stranded Crew: {vehicle} crew calling from {grid}. Vehicle: gone. Salvage: {salvage}. Hope: fading. Send help!", - "Stranded Crew: Priority message! {vehicle} at {grid}! Crew needs pickup! {salvage} salvage ready! Time is critical!", - "Stranded Crew: {vehicle} operators from {grid}. We've got {salvage} salvage and no vehicle. Math doesn't work. Send helo!", - "Stranded Crew: This is {vehicle} crew at {grid}. Salvage recovered: {salvage}. Pride recovered: TBD. Pickup needed: definitely!", - "Stranded Crew: {vehicle} down at {grid}! Crew intact with {salvage} salvage! Vehicle scattered across 50 meters! Extract us!", - "Stranded Crew: Emergency from {grid}! {vehicle} crew needs airlift! {salvage} salvage secured! Don't forget about us!", - "Stranded Crew: {vehicle} survivors at {grid}. We managed to grab {salvage} salvage. Can you manage to grab us?", - "Stranded Crew: This is {vehicle} broadcasting from {grid}. Crew safe. Vehicle unsafe. {salvage} salvage ready. Pickup overdue!", - "Stranded Crew: {vehicle} crew at {grid}. We've got {salvage} salvage and regrets. Send extraction before we have more regrets!", - "Stranded Crew: Urgent call from {grid}! {vehicle} crew stranded! {salvage} salvage on site! Need immediate pickup!", - "Stranded Crew: {vehicle} operators from {grid}. Salvage count: {salvage}. Vehicle count: zero. Help count: requested!", - }, - - -- Load messages (shown when crew boards helicopter - initial contact) - LoadMessages = { - "Crew: Alright, we're in! Get us the hell out of here!", - "Crew: Loaded up! Thank God you showed up!", - "Crew: We're aboard! Let's not hang around!", - "Crew: All accounted for! Move it, move it!", - "Crew: Everyone's in! Punch it!", - "Crew: Secure! Get airborne before they spot us!", - "Crew: We're good! Nice flying, now let's go!", - "Crew: Loaded! You're our hero, let's bounce!", - "Crew: In the bird! Hit the gas!", - "Crew: Everyone's aboard! Go go go!", - "Crew: Mounted up! Don't wait for an invitation!", - "Crew: We're in! Holy shit, that was close!", - "Crew: All souls aboard! Time to leave!", - "Crew: Secure! Best thing I've seen all day!", - "Crew: Loaded! You magnificent bastard!", - "Crew: We're good to go! Pedal to the metal!", - "Crew: All in! Get us home, please!", - "Crew: Aboard! This is not a drill, GO!", - "Crew: Everyone's in! You're a lifesaver!", - "Crew: Locked and loaded! Wait, wrong phrase... just go!", - "Crew: All personnel aboard! Outstanding work!", - "Crew: We're in! Never been happier to see a helicopter!", - "Crew: Boarding complete! Let's get the fuck out of here!", - "Crew: Secure! You deserve a medal for this!", - "Crew: Everyone's aboard! Enemy's probably watching!", - "Crew: All in! Don't let us keep you!", - "Crew: Mounted! Nice flying, seriously!", - "Crew: Loaded up! First round's on us!", - "Crew: We're in! Best Uber rating ever!", - "Crew: All aboard! You're a goddamn angel!", - "Crew: Secure! Nicest thing anyone's done for us!", - "Crew: Everyone's in! Time's wasting!", - "Crew: Boarding complete! You rock!", - "Crew: All souls accounted for! Let's roll!", - "Crew: We're good! Get altitude, fast!", - "Crew: Loaded! I could kiss you!", - "Crew: Everyone's aboard! Don't wait around!", - "Crew: All in! Unbelievable timing!", - "Crew: Secure! You're the best!", - "Crew: Mounted up! We owe you big time!", - "Crew: All aboard! Rotors up, let's go!", - "Crew: We're in! Perfect execution!", - "Crew: Loaded! Smoothest pickup ever!", - "Crew: Everyone's good! Haul ass!", - "Crew: All accounted for! Spectacular flying!", - "Crew: Secure! Get us out of this hellhole!", - "Crew: We're aboard! Don't stick around!", - "Crew: All in! You're incredible!", - "Crew: Loaded up! Time to jet!", - "Crew: Everyone's secure! Outstanding!", - "Crew: All aboard! Best day ever!", - "Crew: We're good! Thank fucking God!", - "Crew: Loaded! You beautiful human being!", - "Crew: All personnel in! Fly fly fly!", - "Crew: Secure! Can't thank you enough!", - "Crew: Everyone's aboard! Green light!", - "Crew: All in! You're a legend!", - "Crew: Loaded! Sweet baby Jesus, let's go!", - "Crew: We're aboard! Best rescue ever!", - "Crew: All accounted for! Move out!", - "Crew: Secure! You saved our asses!", - "Crew: Everyone's in! Don't wait!", - "Crew: All aboard! Brilliant work!", - "Crew: Loaded up! We're getting married!", - "Crew: We're good! Absolutely perfect!", - "Crew: All in! Hit the throttle!", - "Crew: Secure! You're amazing!", - "Crew: Everyone's aboard! Let's leave!", - "Crew: All accounted for! Superb!", - "Crew: Loaded! Get us home safe!", - "Crew: We're in! Textbook pickup!", - "Crew: All aboard! Go go go!", - "Crew: Secure! We love you!", - "Crew: Everyone's good! Don't linger!", - "Crew: All in! Professional as hell!", - "Crew: Loaded up! Time to skedaddle!", - "Crew: We're aboard! You're the GOAT!", - "Crew: All souls in! Get moving!", - "Crew: Secure! We're naming our kids after you!", - "Crew: Everyone's aboard! Clear to leave!", - "Crew: All in! Never doubt yourself!", - "Crew: Loaded! You're a fucking hero!", - "Crew: We're good! Exceptional timing!", - "Crew: All accounted for! Hats off!", - "Crew: Secure! Best pilot ever!", - "Crew: Everyone's in! Throttle up!", - "Crew: All aboard! You're the man!", - "Crew: Loaded up! Pure excellence!", - "Crew: We're aboard! Flawless!", - "Crew: All in! Get us airborne!", - "Crew: Secure! Impressive stuff!", - "Crew: Everyone's good! Don't delay!", - "Crew: All accounted for! Top notch!", - "Crew: Loaded! Words can't express our thanks!", - "Crew: We're in! You're certified awesome!", - "Crew: All aboard! Time to split!", - "Crew: Secure! We're forever grateful!", - "Crew: Everyone's aboard! Wheels up!", - "Crew: All in! Couldn't be better!", - "Crew: Loaded up! You're the best pilot we know!", - "Crew: We're good! Clear skies ahead!", - "Crew: All souls aboard! Let's book it!", - "Crew: Secure! You're our guardian angel!", - }, - - -- Loading messages (shown periodically during boarding process) - LoadingMessages = { - "Crew: Hold still, we're getting in...", - "Crew: Watch your head! Coming through!", - "Crew: Almost there, keep it steady...", - "Crew: Just a sec, getting situated...", - "Crew: Loading up, hang tight...", - "Crew: Careful with Jenkins, he's bleeding pretty bad...", - "Crew: Someone grab the salvage!", - "Crew: Easy does it, wounded coming aboard...", - "Crew: Keep it level, we're climbing in...", - "Crew: Steady now, injured personnel...", - "Crew: Oh God, there's so much blood...", - "Crew: Medic! Where's the first aid kit?", - "Crew: Hold position, almost loaded...", - "Crew: Watch the rotor wash!", - "Crew: Someone's unconscious, careful!", - "Crew: Getting the wounded in first...", - "Crew: Steady as she goes...", - "Crew: Holy hell, Mike's leg is fucked up...", - "Crew: Hurry, he's losing blood fast!", - "Crew: Nice and easy, don't rush...", - "Crew: Everyone watch your step...", - "Crew: Loading wounded, give us a second...", - "Crew: Jesus, that's a lot of shrapnel...", - "Crew: Keep those rotors spinning!", - "Crew: Almost done, standby...", - "Crew: Careful, compound fracture here!", - "Crew: Someone's in shock, move it!", - "Crew: Loading gear, then we're good...", - "Crew: Stay put, we're working...", - "Crew: Damn, this guy's a mess...", - "Crew: Getting everyone situated...", - "Crew: Nice flying, keep it steady...", - "Crew: Hold still while we board...", - "Crew: Watch that head wound!", - "Crew: Everyone stay calm...", - "Crew: Getting the critical cases first...", - "Crew: Standby, loading continues...", - "Crew: Someone's got a sucking chest wound!", - "Crew: Keep that bird steady, sir!", - "Crew: Almost there, patience...", - "Crew: Wounded first, then equipment...", - "Crew: Oh fuck, internal bleeding...", - "Crew: Stay with us, buddy!", - "Crew: Loading process underway...", - "Crew: Keep those engines running!", - "Crew: Careful with his arm, it's shattered!", - "Crew: Getting everyone secured...", - "Crew: Hold your position, pilot!", - "Crew: Someone's not breathing right...", - "Crew: Almost done loading...", - "Crew: Watch your footing!", - "Crew: Traumatic amputation, careful!", - "Crew: Everyone grab something!", - "Crew: Standby, still boarding...", - "Crew: Nice hover, keep it up...", - "Crew: Getting the gear stowed...", - "Crew: Oh man, burns everywhere...", - "Crew: Stay conscious, stay with me!", - "Crew: Loading in progress...", - "Crew: Excellent flying, seriously...", - "Crew: Watch out for that wound!", - "Crew: Everyone move carefully...", - "Crew: He's going into shock!", - "Crew: Almost finished boarding...", - "Crew: Keep it stable, we're working...", - "Crew: Jesus, look at his face...", - "Crew: Getting everyone in...", - "Crew: Hold that position!", - "Crew: Someone's barely conscious...", - "Crew: Loading continues, standby...", - "Crew: Perfect hover, captain!", - "Crew: Careful, severe trauma here...", - "Crew: Everyone's moving slow...", - "Crew: Hold on, still loading...", - "Crew: Damn good flying, pilot!", - "Crew: Watch the blood slick!", - "Crew: Getting situated here...", - "Crew: He needs a hospital NOW...", - "Crew: Almost done, keep steady...", - "Crew: Loading wounded personnel...", - "Crew: Stay with us, soldier!", - "Crew: Keep those rotors turning...", - "Crew: Careful, major injuries...", - "Crew: Everyone board carefully...", - "Crew: Hold position, nearly done...", - "Crew: Oh God, the smell...", - "Crew: Loading critical cases...", - "Crew: Steady now, pilot...", - "Crew: Someone's in bad shape...", - "Crew: Almost loaded up...", - "Crew: Nice hover, excellent control...", - "Crew: Watch the shrapnel wounds!", - "Crew: Everyone move slowly...", - "Crew: He's bleeding out!", - "Crew: Getting everyone aboard...", - "Crew: Hold that hover!", - "Crew: Severe burns, careful!", - "Crew: Loading in progress, standby...", - "Crew: Perfect positioning, sir...", - "Crew: Watch those injuries!", - "Crew: Everyone take it easy...", - "Crew: Almost finished here...", - }, - - -- Unloading messages (shown when delivering crew to MASH) - UnloadingMessages = { - "Crew: Hold steady, do not lift - stretchers are rolling out!", - "Crew: Stay put, we're getting the wounded offloaded!", - "Crew: Keep us grounded, medics are still working inside!", - "Crew: We're at MASH! Thank you so much!", - "Crew: Finally! Get these guys to the docs!", - "Crew: Medical team, we need help here!", - "Crew: We made it! Get the wounded inside!", - "Crew: MASH arrival! These guys need immediate attention!", - "Crew: Unloading! Someone call the surgeons!", - "Crew: We're here! Priority casualties!", - "Crew: Made it alive! You're incredible!", - "Crew: MASH delivery! Critical patients!", - "Crew: Get the medics! We got wounded!", - "Crew: Arrived! These guys are in bad shape!", - "Crew: We're down! Medical emergency!", - "Crew: At MASH! Someone help these men!", - "Crew: Delivered! Thank God for you!", - "Crew: We made it! Get stretchers!", - "Crew: Arrival confirmed! Wounded aboard!", - "Crew: Finally here! Need doctors NOW!", - "Crew: MASH drop-off! Several critical!", - "Crew: We're safe! Get the medical team!", - "Crew: Landed! These boys need surgery!", - "Crew: Delivery complete! You saved lives today!", - "Crew: At medical! Urgent care needed!", - "Crew: We're here! Someone's coding!", - "Crew: MASH arrival! Serious trauma cases!", - "Crew: Made it! Outstanding flying!", - "Crew: Delivered safely! Medical assist required!", - "Crew: We're down! Get the surgical team!", - "Crew: At MASH! Multiple wounded!", - "Crew: Arrived! These guys won't last long!", - "Crew: Delivery! We owe you everything!", - "Crew: MASH landing! Emergency cases!", - "Crew: We made it! Immediate medical attention!", - "Crew: Here safe! Call the surgeons!", - "Crew: Delivered! Some really bad injuries!", - "Crew: At medical! They need help fast!", - "Crew: We're here! You're a hero!", - "Crew: MASH drop! Priority patients!", - "Crew: Arrived alive! Medical emergency!", - "Crew: Delivery complete! Get them inside!", - "Crew: We made it! Someone's critical!", - "Crew: At MASH! Severe casualties!", - "Crew: Landed safely! Thank you!", - "Crew: Delivered! Medical team needed!", - "Crew: We're here! These guys are fucked up!", - "Crew: MASH arrival! Get the doctors!", - "Crew: Made it! They're losing blood!", - "Crew: Arrived! Urgent surgical cases!", - "Crew: We're down! Multiple trauma!", - "Crew: At medical! You saved our asses!", - "Crew: Delivery! Several critical injuries!", - "Crew: MASH landing! They need OR stat!", - "Crew: We made it! Heavy casualties!", - "Crew: Here safely! Medical response!", - "Crew: Delivered! Some won't make it without surgery!", - "Crew: At MASH! Emergency personnel needed!", - "Crew: Arrived! These boys need immediate care!", - "Crew: We're here! Call triage!", - "Crew: MASH drop-off! Serious wounds!", - "Crew: Made it alive! Outstanding work!", - "Crew: Delivered safely! Get the medics!", - "Crew: We're down! They're in rough shape!", - "Crew: At medical! Priority one casualties!", - "Crew: Arrived! You're a lifesaver!", - "Crew: Delivery complete! Medical emergency!", - "Crew: MASH landing! Critical patients!", - "Crew: We made it! Someone's not breathing well!", - "Crew: Here! Get them to surgery!", - "Crew: Delivered! Severe trauma aboard!", - "Crew: At MASH! They need doctors now!", - "Crew: Arrived safely! You're amazing!", - "Crew: We're here! Multiple serious injuries!", - "Crew: MASH drop! Get the surgical team!", - "Crew: Made it! Thank fucking God!", - "Crew: Delivered! Several need immediate surgery!", - "Crew: We're down! Medical assist!", - "Crew: At medical! These guys are critical!", - "Crew: Arrived! You deserve a medal!", - "Crew: Delivery! Heavy casualties!", - "Crew: MASH landing! Emergency patients!", - "Crew: We made it! Get help quick!", - "Crew: Here safely! Brilliant flying!", - "Crew: Delivered! Someone's in bad shape!", - "Crew: At MASH! Urgent care!", - "Crew: Arrived alive! Medical emergency!", - "Crew: We're here! They need triage!", - "Crew: MASH drop-off! You saved lives!", - "Crew: Made it! These boys need help!", - "Crew: Delivered safely! Call the doctors!", - "Crew: We're down! Priority casualties!", - "Crew: At medical! You're our hero!", - "Crew: Arrived! Severe wounds here!", - "Crew: Delivery complete! Get medical personnel!", - "Crew: MASH landing! Critical condition!", - "Crew: We made it! They're barely hanging on!", - "Crew: Here! Immediate medical attention!", - "Crew: Delivered! Someone's dying!", - "Crew: At MASH! Get the OR ready!", - "Crew: Arrived safely! We can't thank you enough!", - "Crew: We're here! Emergency surgery needed!", - "Crew: MASH drop! Multiple trauma!", - "Crew: Made it alive! Exceptional flying!", - "Crew: Delivered! They need help now!", - "Crew: We're down! Medical response required!", - }, - - -- Unload completion messages (shown when offload finishes) - UnloadCompleteMessages = { - "MASH: Offload complete! Medical teams have the wounded!", - "MASH: Patients transferred! You're cleared to lift!", - "MASH: All casualties delivered! Incredible flying!", - "MASH: They're inside! Mission accomplished!", - "MASH: Every patient is in triage! Thank you!", - "MASH: Transfer complete! Head back when ready!", - "MASH: Doctors have them! Outstanding job!", - "MASH: Wounded are inside! You saved them!", - "MASH: Hand-off confirmed! You're good to go!", - "MASH: Casualties secure! Medical team standing by!", - "MASH: Delivery confirmed! Take a breather, pilot!", - "MASH: All stretchers filled! We are done here!", - "MASH: Hospital staff has the patients! Great work!", - "MASH: Unload complete! You nailed that landing!", - "MASH: MASH has control! You're clear, thank you!", - "MASH: Every survivor is inside! Hell yes!", - "MASH: Docs have them! Back to the fight when ready!", - "MASH: Handoff complete! You earned the praise!", - "MASH: Medical team secured the wounded! Legend!", - "MASH: Transfer complete! Outstanding steady hover!", - "MASH: They're in the OR! You rock, pilot!", - "MASH: Casualties delivered! Spin it back up when ready!", - "MASH: MASH confirms receipt! You're a lifesaver!", - "MASH: Every patient is safe! Mission complete!", - }, - - -- Enroute messages (periodic chatter with bearing/distance to MASH) - EnrouteToMashMessages = { - "Crew: Steady hands—{mash} sits at bearing {brg}°, {rng} {rng_u} ahead; patients are trying to nap.", - "Crew: Nav board says {mash} is {brg}° for {rng} {rng_u}; keep it gentle so the IVs stay put.", - "Crew: If you hold {brg}° for {rng} {rng_u}, {mash} will have hot coffee waiting—no promises on taste.", - "Crew: Confirmed, {mash} straight off the nose at {brg}°, {rng} {rng_u}; wounded are counting on you.", - "Crew: Stay on {brg}° for {rng} {rng_u} and we’ll roll into {mash} like heroes instead of hooligans.", - "Crew: Tilt a hair left—{mash} lies {brg}° at {rng} {rng_u}; let’s not overshoot the hospital.", - "Crew: Keep the climb smooth; {mash} is {brg}° at {rng} {rng_u} and the patients already look green.", - "Crew: Plot shows {mash} bearing {brg}°, range {rng} {rng_u}; mother hen wants her chicks delivered.", - "Crew: Hold that heading {brg}° and we’ll be on final to {mash} in {rng} {rng_u}; medics are on standby.", - "Crew: Reminder—{mash} is {brg}° at {rng} {rng_u}; try not to buzz the command tent this run.", - "Crew: Flight doc says keep turbulence down; {mash} sits {brg}° out at {rng} {rng_u}.", - "Crew: Stay focused—{mash} ahead {brg}°, {rng} {rng_u}; every bump costs us more paperwork.", - "Crew: We owe those medics a beer; {mash} is {brg}° for {rng} {rng_u}, so let’s get there in one piece.", - "Crew: Update from ops: {mash} remains {brg}° at {rng} {rng_u}; throttle down before the pad sneaks up.", - "Crew: Patients are asking if this thing comes with a smoother ride—{mash} {brg}°, {rng} {rng_u} to go.", - "Crew: Keep your cool—{mash} is {brg}° at {rng} {rng_u}; med bay is laying out stretchers now.", - "Crew: Good news, {mash} has fresh morphine; bad news, it’s {brg}° and {rng} {rng_u} away—step on it.", - "Crew: Command wants ETA—tell them {mash} is {brg}° for {rng} {rng_u} and we’re hauling wounded and sass.", - "Crew: That squeak you hear is the stretcher—stay on {brg}° for {rng} {rng_u} to {mash}.", - "Crew: Don’t mind the swearing; we’re {rng} {rng_u} from {mash} on bearing {brg}° and the pain meds wore off.", - "Crew: Eyes outside—{mash} sits {brg}° at {rng} {rng_u}; flak gunners better keep their heads down.", - "Crew: Weather’s clear—{mash} is {brg}° out {rng} {rng_u}; let’s not invent new IFR procedures.", - "Crew: Remember your autorotation drills? Neither do we. Fly {brg}° for {rng} {rng_u} to {mash} and keep her humming.", - "Crew: The guy on stretcher two wants to know if {mash} is really {brg}° at {rng} {rng_u}; I told him yes, please prove me right.", - "Crew: Rotor check good; {mash} bearing {brg}°, distance {rng} {rng_u}. Try to act like professionals.", - "Crew: Stay low and fast—{mash} {brg}° {rng} {rng_u}; enemy radios are whining already.", - "Crew: You’re doing great—just keep {brg}° for {rng} {rng_u} and {mash} will take the baton.", - "Crew: Map scribble says {mash} is {brg}° and {rng} {rng_u}; let’s prove cartography still works.", - "Crew: Pilot, the patients voted: less banking, more {mash}. Bearing {brg}°, {rng} {rng_u}.", - "Crew: We cross the line into {mash} territory in {rng} {rng_u} at {brg}°; keep the blades happy.", - "Crew: Hot tip—{mash} chefs saved us soup if we make {brg}° in {rng} {rng_u}; pretty sure it’s edible.", - "Crew: Another bump like that and I’m filing a complaint; {mash} is {brg}° at {rng} {rng_u}, so aim true.", - "Crew: The wounded in back just made side bets on landing—bearing {brg}°, range {rng} {rng_u} to {mash}.", - "Crew: Stay on that compass—{mash} sits {brg}° at {rng} {rng_u}; medics already prepped the triage tent.", - "Crew: Copy tower—{mash} runway metaphorically lies {brg}° and {rng} {rng_u} ahead; no victory rolls.", - "Crew: Someone alert the chaplain—we’re {rng} {rng_u} out from {mash} on {brg}° and our patients could use jokes.", - "Crew: Keep chatter clear—{mash} is {brg}° away at {rng} {rng_u}; let’s land before the morphine fades.", - "Crew: They promised me coffee at {mash} if we stick {brg}° for {rng} {rng_u}; don’t ruin this.", - "Crew: Plotting intercept—{mash} coordinates show {brg}°/{rng} {rng_u}; maintain this track.", - "Crew: I know the gauges say fine but the guys in back disagree; {mash} {brg}°, {rng} {rng_u}.", - "Crew: Remember, no barrel rolls; {mash} lies {brg}° at {rng} {rng_u}, and the surgeon will kill us if we’re late.", - "Crew: Keep the skids level; {mash} is {brg}° and {rng} {rng_u} away begging for customers.", - "Crew: We’re on schedule—{mash} sits {brg}° at {rng} {rng_u}; try not to invent new delays.", - "Crew: Latest wind check says {mash} {brg}°, {rng} {rng_u}; adjust trim before the patients revolt.", - "Crew: The medic in back just promised cookies if we hit {brg}° for {rng} {rng_u} to {mash}.", - "Crew: Hold blades steady—{mash} is {brg}° at {rng} {rng_u}; stretcher straps can only do so much.", - "Crew: Copy you’re bored, but {mash} is {brg}° for {rng} {rng_u}; no scenic detours today.", - "Crew: If you overshoot {mash} by {rng} {rng_u} I’m telling command it was deliberate; target bearing {brg}°.", - "Crew: Serious faces—we’re {rng} {rng_u} out from {mash} on {brg}° and these folks hurt like hell.", - "Crew: Hey pilot, the guy with the busted leg says thanks—just keep {brg}° for {rng} {rng_u} to {mash}.", - "Crew: That was a nice thermal—maybe avoid the next one; {mash} sits {brg}° at {rng} {rng_u}.", - "Crew: Keep those eyes up; {mash} is {brg}° away {rng} {rng_u}; CAS flights are buzzing around.", - "Crew: Reminder: {mash} won’t accept deliveries dumped on the lawn; {brg}° and {rng} {rng_u} to touchdown.", - "Crew: Ops pinged again; told them we’re {rng} {rng_u} from {mash} on heading {brg}° and flying like pros.", - "Crew: We promised the patients a soft landing; {mash} bearing {brg}°, distance {rng} {rng_u}.", - "Crew: Keep the profile low—{mash} is {brg}° at {rng} {rng_u}; AAA spots are grumpy today.", - "Crew: Message from tower: {mash} pad is clear; track {brg}° for {rng} {rng_u} and watch the dust.", - "Crew: Someone in back just yanked an IV—slow the hell down; {mash} {brg}°, {rng} {rng_u}.", - "Crew: We’re so close I can smell antiseptic—{mash} is {brg}° and {rng} {rng_u} from here.", - "Crew: If we shave more time the medics might actually smile; {mash} lies {brg}° at {rng} {rng_u}.", - "Crew: Friendly reminder—{mash} is {brg}° at {rng} {rng_u}; try not to park on their tent again.", - "Crew: The patients voted you best pilot if we hit {mash} at {brg}° in {rng} {rng_u}; don’t blow the election.", - "Crew: I’ve got morphine bets riding on you; {mash} sits {brg}° for {rng} {rng_u}.", - "Crew: Keep your head in the game—{mash} {brg}°, {rng} {rng_u}; enemy gunners love tall rotor masts.", - "Crew: That rattle is the litter, not the engine; {mash} is {brg}° and {rng} {rng_u} out.", - "Crew: Flight lead wants a status—reported {mash} bearing {brg}°, {rng} {rng_u}; keep us honest.", - "Crew: Patient three says thanks for not crashing—yet; {mash} {brg}°, {rng} {rng_u}.", - "Crew: If you see the chaplain waving, you missed—{mash} sits {brg}° at {rng} {rng_u}.", - "Crew: Med bay just radioed; they’re warming blankets. That’s {mash} {brg}° at {rng} {rng_u}.", - "Crew: Stay locked on {brg}° for {rng} {rng_u}; {mash} already cleared a pad.", - "Crew: Little turbulence ahead; {mash} bearing {brg}°, {rng} {rng_u}; grip it and grin.", - "Crew: The guy on the stretcher wants to know if we’re lost—tell him {mash} {brg}°, {rng} {rng_u}.", - "Crew: Hold altitude; {mash} is {brg}° away {rng} {rng_u} and the medics hate surprise autorotations.", - "Crew: Confirming nav—{mash} at {brg}°, {rng} {rng_u}; you keep flying, we’ll keep them calm.", - "Crew: If anyone asks, yes we’re inbound; {mash} sits {brg}° {rng} {rng_u} out.", - "Crew: Think happy thoughts—{mash} is {brg}° at {rng} {rng_u}; patients can smell fear.", - "Crew: Quit sightseeing—{mash} lies {brg}° and {rng} {rng_u}; let’s deliver the meat wagon.", - "Crew: Keep that nose pointed {brg}°; {mash} is only {rng} {rng_u} away and my nerves are shot.", - "Crew: We promised a fast ride; {mash} sits {brg}° at {rng} {rng_u}. No pressure.", - "Crew: You’re lined up perfect—{mash} {brg}°, {rng} {rng_u}; now just keep it that way.", - "Crew: The surgeon texted—he wants his patients now. {mash} bearing {brg}°, {rng} {rng_u}.", - "Crew: The wounded are timing us; {mash} is {brg}° at {rng} {rng_u} so don’t dilly-dally.", - "Crew: Another five minutes and {mash} will start nagging—hold {brg}° for {rng} {rng_u}.", - "Crew: Keep the blade slap mellow; {mash} sits {brg}° at {rng} {rng_u}.", - "Crew: Airspeed’s good; {mash} is {brg}° for {rng} {rng_u}; cue inspirational soundtrack.", - "Crew: Patient four says if we keep {brg}° for {rng} {rng_u}, drinks are on him at {mash}.", - "Crew: Don’t ask why the stretcher smells like smoke; just fly {brg}° {rng} {rng_u} to {mash}.", - "Crew: Tower says we’re clear direct {mash}; bearing {brg}°, {rng} {rng_u}.", - "Crew: If the engine coughs again we’re walking—{mash} sits {brg}° at {rng} {rng_u}; keep the RPM up.", - "Crew: Calm voices only—{mash} sits {brg}° {rng} {rng_u}; the patients listen to tone more than words.", - "Crew: Promise the guys in back we’ll hit {brg}° for {rng} {rng_u} and land like silk at {mash}.", - "Crew: There’s a small bet you’ll flare too high; prove them wrong—{mash} {brg}°, {rng} {rng_u}.", - "Crew: The medic wants you to skip the cowboy routine; {mash} lies {brg}° at {rng} {rng_u}.", - "Crew: That vibration is fine; what’s not fine is missing {mash} at {brg}° in {rng} {rng_u}.", - "Crew: Keep the collective steady—{mash} {brg}°, {rng} {rng_u}; we’re hauling precious cargo.", - "Crew: Someone promised me a hot meal at {mash}; stay on {brg}° for {rng} {rng_u} and make it happen.", - "Crew: The patients say if you wobble again they’re walking; {mash} {brg}°, {rng} {rng_u}.", - "Crew: Hold that horizon—{mash} is {brg}° for {rng} {rng_u}; the doc already scrubbed in.", - "Crew: Eyes on the prize—{mash} {brg}°, {rng} {rng_u}; don’t let the wind push us off.", - "Crew: Finish strong; {mash} sits {brg}° {rng} {rng_u}. Wheels down and we’re heroes again.", - }, - - -- Crew unit types per coalition (fallback if not specified in catalog) - CrewUnitTypes = { - [coalition.side.BLUE] = 'Soldier M4', - [coalition.side.RED] = 'Paratrooper RPG-16', -- Try Russian paratrooper instead - }, - - -- MANPADS unit types per coalition (one random crew member gets this weapon) - ManPadUnitTypes = { - [coalition.side.BLUE] = 'Soldier stinger', - [coalition.side.RED] = 'SA-18 Igla manpad', - }, - - -- Respawn settings - RespawnOnPickup = true, -- if true, vehicle respawns when crew loaded into helo - RespawnOffset = 15, -- meters from original death position - RespawnSameHeading = true, -- preserve original heading - - -- Automatic pickup/unload settings - AutoPickup = { - Enabled = true, -- if true, crews will be picked up automatically when helicopter lands nearby - MaxDistance = 30, -- meters - max distance for automatic crew pickup - CheckInterval = 3, -- seconds between checks for landed helicopters - RequireGroundContact = true, -- when true, helicopter must be firmly on the ground before crews move - GroundContactAGL = 4, -- meters AGL threshold treated as “landed” for ground contact purposes - MaxLandingSpeed = 2, -- m/s ground speed limit while parked; prevents chasing sliding helicopters - LoadDelay = 15, -- seconds crews need to board after reaching helicopter (must stay landed) - SettledAGL = 6.0, -- maximum AGL considered safely settled during boarding hold - AirAbortGrace = 2, -- seconds of hover tolerated during boarding before aborting - }, - - AutoUnload = { - Enabled = true, -- if true, crews automatically unload when landed in MASH zone - UnloadDelay = 15, -- seconds after landing before auto-unload triggers - GroundContactAGL = 3.5, -- meters AGL treated as “on the ground” for auto-unload (taller skids/mod helos) - SettledAGL = 6.0, -- maximum AGL considered safely settled for the unload hold to run (relative to terrain) - MaxLandingSpeed = 2.0, -- m/s ground speed limit while holding to unload - AirAbortGrace = 2, -- seconds of hover wiggle tolerated before aborting the unload hold - }, - - EnrouteMessages = { - Enabled = true, - Interval = 123, -- seconds between in-flight status quips while MEDEVAC patients onboard - }, - - -- Salvage system - Salvage = { - Enabled = true, - PoolType = 'global', -- 'global' = coalition-wide pool - DefaultValue = 1, -- default salvageValue if not in catalog - ShowInStatus = true, -- show salvage points in F10 status menu - AutoApply = true, -- auto-use salvage when out of stock (no manual confirmation) - AllowAnyItem = true, -- can build items that never had inventory using salvage - }, - - -- Map markers for downed crews - MapMarkers = { - Enabled = true, - IconText = '🔴 MEDEVAC', -- prefix for marker text - ShowGrid = true, -- include grid coordinates in marker - ShowTimeRemaining = true, -- show expiration time in marker - ShowSalvageValue = true, -- show salvage value in marker - }, - - -- Warning messages before crew timeout - Warnings = { - { time = 900, message = 'MEDEVAC: {crew} at {grid} has 15 minutes remaining!' }, - { time = 300, message = 'URGENT MEDEVAC: {crew} at {grid} will be KIA in 5 minutes!' }, - }, - - MASHZoneRadius = 500, -- default radius for MASH zones - MASHZoneColors = { - border = {1, 1, 0, 0.85}, -- yellow border - fill = {1, 0.75, 0.8, 0.25}, -- pink fill - }, - - -- Mobile MASH (player-deployable via crates) - MobileMASH = { - Enabled = true, - ZoneRadius = 500, -- radius of Mobile MASH zone in meters - CrateRecipeKey = 'MOBILE_MASH', -- catalog key for building mobile MASH - AnnouncementInterval = 1800, -- 30 mins between announcements - BeaconFrequency = '30.0 FM', -- radio frequency for announcements - Destructible = true, - VehicleTypes = { - [coalition.side.BLUE] = 'M-113', -- Medical variant for BLUE - [coalition.side.RED] = 'BTR_D', -- Medical/transport variant for RED - }, - AutoIncrementName = true, -- "Mobile MASH 1", "Mobile MASH 2"... - }, - - -- Statistics tracking - Statistics = { - Enabled = true, - TrackByPlayer = false, -- if true, track per-player stats (not yet implemented) - }, -} - --- ========================= --- Sling-Load Salvage Configuration (MOVED) --- ========================= --- #region SlingLoadSalvage Config --- NOTE: SlingLoadSalvage configuration has been MOVED into CTLD.Config.SlingLoadSalvage --- so that it properly gets copied to each CTLD instance via DeepCopy/DeepMerge. --- The old CTLD.SlingLoadSalvage global definition here is removed to avoid confusion. --- See CTLD.Config.SlingLoadSalvage above for the actual configuration. --- #endregion SlingLoadSalvage Config ---=================================================================================================================================================== --- #endregion MEDEVAC Config - - -- #region State - -- Internal state tables -CTLD._instances = CTLD._instances or {} -CTLD._crates = {} -- [crateName] = { key, zone, side, spawnTime, point } -CTLD._troopsLoaded = {} -- [groupName] = { count, typeKey, weightKg } -CTLD._loadedCrates = {} -- [groupName] = { total=n, totalWeightKg=w, byKey = { key -> count } } -CTLD._loadedTroopTypes = {} -- [groupName] = { total=n, byType = { typeKey -> count }, labels = { typeKey -> label } } -CTLD._deployedTroops = {} -- [groupName] = { typeKey, count, side, spawnTime, point, weightKg } -CTLD._hoverState = {} -- [unitName] = { targetCrate=name, startTime=t } -CTLD._unitLast = {} -- [unitName] = { x, z, t } -CTLD._coachState = {} -- [unitName] = { lastKeyTimes = {key->time}, lastHint = "", phase = "", lastPhaseMsg = 0, target = crateName, holdStart = nil } -CTLD._msgState = { } -- messaging throttle state: [scopeKey] = { lastKeyTimes = { key -> time } } -CTLD._buildConfirm = {} -- [groupName] = time of first build request (awaiting confirmation) -CTLD._buildCooldown = {} -- [groupName] = time of last successful build -CTLD._NextMarkupId = 10000 -- global-ish id generator shared by instances for map drawings --- Spatial indexing for hover pickup performance -CTLD._spatialGrid = CTLD._spatialGrid or {} -- [gridKey] = { crates = {name->meta}, troops = {name->meta} } -CTLD._spatialGridSize = 500 -- meters per grid cell (tunable based on hover pickup distance) --- Inventory state -CTLD._stockByZone = CTLD._stockByZone or {} -- [zoneName] = { [crateKey] = count } -CTLD._inStockMenus = CTLD._inStockMenus or {} -- per-group filtered menu handles -CTLD._jtacReservedCodes = CTLD._jtacReservedCodes or { - [coalition.side.BLUE] = {}, - [coalition.side.RED] = {}, - [coalition.side.NEUTRAL] = {}, -} --- MEDEVAC state -CTLD._medevacCrews = CTLD._medevacCrews or {} -- [crewGroupName] = { vehicleType, side, spawnTime, position, salvageValue, markerID, originalHeading, requestTime, warningsSent } -CTLD._salvagePoints = CTLD._salvagePoints or {} -- [coalition.side] = points (global pool) -CTLD._mashZones = CTLD._mashZones or {} -- [zoneName] = { zone, side, isMobile, unitName (if mobile) } -CTLD._mobileMASHCounter = CTLD._mobileMASHCounter or { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0 } -CTLD._medevacStats = CTLD._medevacStats or { -- [coalition.side] = { spawned, rescued, delivered, timedOut, killed, salvageEarned, vehiclesRespawned } - [coalition.side.BLUE] = { spawned = 0, rescued = 0, delivered = 0, timedOut = 0, killed = 0, salvageEarned = 0, vehiclesRespawned = 0, salvageUsed = 0 }, - [coalition.side.RED] = { spawned = 0, rescued = 0, delivered = 0, timedOut = 0, killed = 0, salvageEarned = 0, vehiclesRespawned = 0, salvageUsed = 0 }, -} -CTLD._medevacUnloadStates = CTLD._medevacUnloadStates or {} -- [groupName] = { startTime, delay, holdAnnounced, nextReminder } -CTLD._medevacLoadStates = CTLD._medevacLoadStates or {} -- [groupName] = { startTime, delay, crewGroupName, crewData, holdAnnounced, nextReminder } -CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} -- [groupName] = { nextSend, lastIndex } - --- Sling-Load Salvage state -CTLD._salvageCrates = CTLD._salvageCrates or {} -- [crateName] = { side, weight, spawnTime, position, initialHealth, rewardValue, warningsSent, staticObject, crateClass } -CTLD._salvageDropZones = CTLD._salvageDropZones or {} -- [zoneName] = { zone, side, active } -CTLD._salvageStats = CTLD._salvageStats or { -- [coalition.side] = { spawned, delivered, expired, totalWeight, totalReward } - [coalition.side.BLUE] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 }, - [coalition.side.RED] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 }, -} --- One-shot timer tracking for cleanup -CTLD._pendingTimers = CTLD._pendingTimers or {} -- [timerId] = true - --- FARP System state -CTLD._farpData = CTLD._farpData or {} -- [fobZoneName] = { stage = 1/2/3, statics = {name1, name2...}, coalition = side } -CTLD._farpZones = CTLD._farpZones or {} -- [farpZoneName] = { zone, side, stage } - -local function _distanceXZ(a, b) - if not a or not b then return math.huge end - local dx = (a.x or 0) - (b.x or 0) - local dz = (a.z or 0) - (b.z or 0) - return math.sqrt(dx * dx + dz * dz) -end - -local function _buildSphereVolume(point, radius) - local px = (point and point.x) or 0 - local pz = (point and point.z) or 0 - local py = (point and (point.y or point.alt)) - if py == nil and land and land.getHeight then - local ok, h = pcall(land.getHeight, { x = px, y = pz }) - if ok and type(h) == 'number' then py = h end - end - py = py or 0 - local volId = (world and world.VolumeType and world.VolumeType.SPHERE) or 0 - return { - id = volId, - params = { - point = { x = px, y = py, z = pz }, - radius = radius or 0, - } - } -end - --- Check if a crate is being sling-loaded by scanning for nearby helicopters --- Static objects don't have inAir() method, so we check if any unit is carrying it -local function _isCrateHooked(crateObj) - if not crateObj then return false end - - -- For dynamic objects (vehicles), inAir() works - if crateObj.inAir then - local ok, result = pcall(function() return crateObj:inAir() end) - if ok and result then return true end - end - - -- For static objects: check if the crate itself is elevated above ground - -- This indicates it's actually being carried, not just near a helicopter - local cratePos = crateObj:getPoint() - if not cratePos then return false end - - -- Get ground height at crate position - local landHeight = land.getHeight({x = cratePos.x, y = cratePos.z}) - if not landHeight then landHeight = 0 end - - -- If crate is more than 2 meters above ground, it's being carried - -- (accounts for terrain variations and crate size) - local heightAboveGround = cratePos.y - landHeight - if heightAboveGround > 2 then - return true - end - - return false -end - -local function _fmtTemplate(tpl, data) - if not tpl or tpl == '' then return '' end - -- Support placeholder keys with underscores (e.g., {zone_dist_u}) - return (tpl:gsub('{([%w_]+)}', function(k) - local v = data and data[k] - -- If value is missing, leave placeholder intact to aid debugging - if v == nil then return '{'..k..'}' end - return tostring(v) - end)) -end - -function CTLD:_FindNearestFriendlyTransport(position, side, radius) - if not position or not side then return nil end - radius = radius or 600 - local bestGroupName - local bestDist = math.huge - local sphere = _buildSphereVolume(position, radius) - world.searchObjects(Object.Category.UNIT, sphere, function(obj) - if not obj or (not obj.isExist or not obj:isExist()) then return true end - if not obj.getCoalition or obj:getCoalition() ~= side then return true end - local grp = obj.getGroup and obj:getGroup() - if not grp then return true end - local grpName = grp.getName and grp:getName() - if not grpName then return true end - local objPos = obj.getPoint and obj:getPoint() - local dist = _distanceXZ(position, objPos) - if dist < bestDist then - bestDist = dist - bestGroupName = grpName - end - return true - end) - if not bestGroupName then return nil end - local mooseGrp = GROUP:FindByName(bestGroupName) - return mooseGrp -end - -function CTLD:_SendSalvageHint(meta, messageKey, data, position, cooldown) - if not meta or not messageKey then return end - cooldown = cooldown or 10 - meta.hintCooldowns = meta.hintCooldowns or {} - local hintKey = messageKey - if data and data.zone then hintKey = hintKey .. ':' .. data.zone end - local now = timer.getTime() - local last = meta.hintCooldowns[hintKey] or 0 - if (now - last) < cooldown then return end - meta.hintCooldowns[hintKey] = now - - local template = self.Messages and self.Messages[messageKey] - if not template then return end - local text = _fmtTemplate(template, data or {}) - if not text or text == '' then return end - - local recipient = self:_FindNearestFriendlyTransport(position, meta.side, 700) - local recipientLabel - if recipient then - _msgGroup(recipient, text) - recipientLabel = recipient.GetName and recipient:GetName() or 'nearest transport' - else - _msgCoalition(meta.side, text) - recipientLabel = string.format('coalition-%s', meta.side == coalition.side.BLUE and 'BLUE' or 'RED') - end - - local zoneLabel = (data and data.zone) and (' zone '..tostring(data.zone)) or '' - _logInfo(string.format('[SlingLoadSalvage] Hint %s -> %s for crate %s%s', - messageKey, - recipientLabel or 'unknown recipient', - data and data.id or 'unknown', - zoneLabel)) -end - -function CTLD:_CheckCrateZoneHints(crateName, meta, cratePos) - if not meta or not cratePos then return end - local zoneSets = { - { list = self.PickupZones, active = self._ZoneActive and self._ZoneActive.Pickup, label = 'Pickup' }, - { list = self.FOBZones, active = self._ZoneActive and self._ZoneActive.FOB, label = 'FOB' }, - { list = self.DropZones, active = self._ZoneActive and self._ZoneActive.Drop, label = 'Drop' }, - } - - for _, entry in ipairs(zoneSets) do - local zones = entry.list or {} - if #zones > 0 then - for _, zone in ipairs(zones) do - local zoneName = zone:GetName() - local isActive = true - if entry.active and zoneName then - isActive = (entry.active[zoneName] ~= false) - end - if isActive and zone:IsVec3InZone(cratePos) then - self:_SendSalvageHint(meta, 'slingload_salvage_wrong_zone', { - id = crateName, - zone = zoneName, - zone_type = entry.label, - }, cratePos, 15) - return - end - end - end - end -end - - -- #endregion State - --- ========================= --- Utilities --- ========================= - -- #region Utilities - --- Select a random crate spawn point inside the zone while respecting separation rules. -function CTLD:_computeCrateSpawnPoint(zone, opts) - opts = opts or {} - if not zone or not zone.GetPointVec3 then return nil end - - local centerVec = zone:GetPointVec3() - if not centerVec then return nil end - local center = { x = centerVec.x, z = centerVec.z } - local rZone = self:_getZoneRadius(zone) - - local edgeBuf = math.max(0, opts.edgeBuffer or self.Config.PickupZoneSpawnEdgeBuffer or 10) - local minOff = math.max(0, opts.minOffset or self.Config.PickupZoneSpawnMinOffset or 5) - local extraPad = math.max(0, opts.additionalEdgeBuffer or 0) - local rMax = math.max(0, (rZone or 150) - edgeBuf - extraPad) - if rMax < 0 then rMax = 0 end - - local tries = math.max(1, opts.tries or self.Config.CrateSpawnSeparationTries or 6) - local minSep = opts.minSeparation - if minSep == nil then - minSep = math.max(0, self.Config.CrateSpawnMinSeparation or 7) - end - - local skipSeparation = opts.skipSeparationCheck == true - local ignoreCrates = {} - if opts.ignoreCrates then - for name,_ in pairs(opts.ignoreCrates) do - ignoreCrates[name] = true - end - end - - local preferred = opts.preferredPoint - local usePreferred = (preferred ~= nil) - - local function candidate() - if usePreferred then - usePreferred = false - return { x = preferred.x, z = preferred.z } - end - if (self.Config.PickupZoneSpawnRandomize == false) or rMax <= 0 then - return { x = center.x, z = center.z } - end - local rr - if rMax > minOff then - rr = minOff + math.sqrt(math.random()) * (rMax - minOff) - else - rr = rMax - end - local th = math.random() * 2 * math.pi - return { x = center.x + rr * math.cos(th), z = center.z + rr * math.sin(th) } - end - - local function isClear(pt) - if skipSeparation or minSep <= 0 then return true end - for name, meta in pairs(CTLD._crates) do - if not ignoreCrates[name] and meta and meta.side == self.Side and meta.point then - local dx = (meta.point.x - pt.x) - local dz = (meta.point.z - pt.z) - if (dx*dx + dz*dz) < (minSep*minSep) then - return false - end - end - end - return true - end - - local chosen = candidate() - if not chosen then return nil end - if not isClear(chosen) then - for _ = 1, tries - 1 do - local c = candidate() - if c and isClear(c) then - chosen = c - break - end - end - end - return chosen -end - --- Build a centered grid of offsets for cluster placement, keeping index 1 at the origin. -function CTLD:_buildClusterOffsets(count, spacing) - local offsets = {} - if count <= 0 then return offsets, 0, 0 end - - offsets[1] = { x = 0, z = 0 } - if count == 1 then return offsets, 1, 1 end - - local perRow = math.ceil(math.sqrt(count)) - local rows = math.ceil(count / perRow) - local positions = {} - - for r = 1, rows do - for c = 1, perRow do - local ox = (c - ((perRow + 1) / 2)) * spacing - local oz = (r - ((rows + 1) / 2)) * spacing - if math.abs(ox) > 0.01 or math.abs(oz) > 0.01 then - positions[#positions + 1] = { x = ox, z = oz } - end - end - end - - table.sort(positions, function(a, b) - local da = a.x * a.x + a.z * a.z - local db = b.x * b.x + b.z * b.z - if da == db then - if a.x == b.x then return a.z < b.z end - return a.x < b.x - end - return da < db - end) - - local idx = 2 - for _,pos in ipairs(positions) do - if idx > count then break end - offsets[idx] = pos - idx = idx + 1 - end - - return offsets, perRow, rows -end - --- Safe deep copy: prefer MOOSE UTILS.DeepCopy when available; fallback to Lua implementation -local function _deepcopy_fallback(obj, seen) - if type(obj) ~= 'table' then return obj end - seen = seen or {} - if seen[obj] then return seen[obj] end - local res = {} - seen[obj] = res - for k, v in pairs(obj) do - res[_deepcopy_fallback(k, seen)] = _deepcopy_fallback(v, seen) - end - local mt = getmetatable(obj) - if mt then setmetatable(res, mt) end - return res -end - -local function DeepCopy(obj) - if _G.UTILS and type(UTILS.DeepCopy) == 'function' then - return UTILS.DeepCopy(obj) - end - return _deepcopy_fallback(obj) -end - --- Deep-merge src into dst (recursively). Arrays/lists in src replace dst. -local function DeepMerge(dst, src) - if type(dst) ~= 'table' or type(src) ~= 'table' then return src end - for k, v in pairs(src) do - if type(v) == 'table' then - local isArray = (rawget(v, 1) ~= nil) - if isArray then - dst[k] = DeepCopy(v) - else - dst[k] = DeepMerge(dst[k] or {}, v) - end - else - dst[k] = v - end - end - return dst -end - -local function _trim(value) - if type(value) ~= 'string' then return nil end - return value:match('^%s*(.-)%s*$') -end - -local function _addUniqueString(out, seen, value) - local v = _trim(value) - if not v or v == '' then return end - if not seen[v] then - seen[v] = true - out[#out + 1] = v - end -end - -local function _collectTypesFromBuilder(builder) - local out = {} - if type(builder) ~= 'function' then return out end - local ok, template = pcall(builder, { x = 0, y = 0, z = 0 }, 0) - if not ok or type(template) ~= 'table' then return out end - local units = template.units - if type(units) ~= 'table' then return out end - local seen = {} - for _,unit in pairs(units) do - if type(unit) == 'table' then - _addUniqueString(out, seen, unit.type) - end - end - return out -end - -local _unitTypeCache = {} - -local function _tableHasEntries(tbl) - if type(tbl) ~= 'table' then return false end - for _,_ in pairs(tbl) do return true end - return false -end - -local function _tableSize(tbl) - if type(tbl) ~= 'table' then return 0 end - local count = 0 - for _,_ in pairs(tbl) do count = count + 1 end - return count -end - -local function _isUnitDatabaseReady() - local dbRoot = rawget(_G, 'db') - if type(dbRoot) ~= 'table' then return false, 'missing' end - local unitByType = dbRoot.unit_by_type - if type(unitByType) ~= 'table' then return false, 'no_unit_by_type' end - if _tableHasEntries(unitByType) then return true, 'ok' end - if _tableHasEntries(dbRoot.units) or _tableHasEntries(dbRoot.Units) then - -- Older builds expose data under units/Units before unit_by_type is populated - return true, 'ok' - end - return false, 'empty' -end - -local function _unitTypeExists(typeName) - local key = _trim(typeName) - if not key or key == '' then return false end - if _unitTypeCache[key] ~= nil then return _unitTypeCache[key] end - - local exists = false - local visited = {} - - local dbRoot = rawget(_G, 'db') - - -- Fast-path: common lookup table exposed by DCS - if type(dbRoot) == 'table' and type(dbRoot.unit_by_type) == 'table' then - if dbRoot.unit_by_type[key] ~= nil then - _unitTypeCache[key] = true - return true - end - end - - local function walk(tbl) - if exists or type(tbl) ~= 'table' or visited[tbl] then return end - visited[tbl] = true - - if tbl.type == key or tbl.Type == key or tbl.unitType == key or tbl.typeName == key or tbl.Name == key then - exists = true - return - end - - for k,v in pairs(tbl) do - if type(k) == 'string' and k == key then - exists = true - return - end - if type(v) == 'string' then - if (k == 'type' or k == 'Type' or k == 'unitType' or k == 'typeName' or k == 'Name') and v == key then - exists = true - return - end - elseif type(v) == 'table' then - walk(v) - if exists then return end - end - end - end - - if type(dbRoot) == 'table' then - if dbRoot.units then walk(dbRoot.units) end - if not exists and dbRoot.Units then walk(dbRoot.Units) end - if not exists and dbRoot.unit_by_type then walk(dbRoot.unit_by_type) end - end - - _unitTypeCache[key] = exists - return exists -end - --- Spatial indexing helpers for performance optimization -local function _getSpatialGridKey(x, z) - local gridSize = CTLD._spatialGridSize or 500 - local gx = math.floor(x / gridSize) - local gz = math.floor(z / gridSize) - return string.format("%d_%d", gx, gz) -end - -local function _addToSpatialGrid(name, meta, itemType) - if not meta or not meta.point then return end - local key = _getSpatialGridKey(meta.point.x, meta.point.z) - CTLD._spatialGrid[key] = CTLD._spatialGrid[key] or { crates = {}, troops = {} } - if itemType == 'crate' then - CTLD._spatialGrid[key].crates[name] = meta - elseif itemType == 'troops' then - CTLD._spatialGrid[key].troops[name] = meta - end -end - -local function _removeFromSpatialGrid(name, point, itemType) - if not point then return end - local key = _getSpatialGridKey(point.x, point.z) - local cell = CTLD._spatialGrid[key] - if cell then - if itemType == 'crate' then - cell.crates[name] = nil - elseif itemType == 'troops' then - cell.troops[name] = nil - end - -- Clean up empty cells - if not next(cell.crates) and not next(cell.troops) then - CTLD._spatialGrid[key] = nil - end - end -end - -local function _getNearbyFromSpatialGrid(x, z, maxDistance) - local gridSize = CTLD._spatialGridSize or 500 - local cellRadius = math.ceil(maxDistance / gridSize) + 1 - local centerGX = math.floor(x / gridSize) - local centerGZ = math.floor(z / gridSize) - - local nearby = { crates = {}, troops = {} } - for dx = -cellRadius, cellRadius do - for dz = -cellRadius, cellRadius do - local key = string.format("%d_%d", centerGX + dx, centerGZ + dz) - local cell = CTLD._spatialGrid[key] - if cell then - for name, meta in pairs(cell.crates) do - nearby.crates[name] = meta - end - for name, meta in pairs(cell.troops) do - nearby.troops[name] = meta - end - end - end - end - return nearby -end - -local function _isIn(list, value) - for _,v in ipairs(list or {}) do if v == value then return true end end - return false -end - -local function _vec3(x, y, z) - return { x = x, y = y, z = z } -end - -local function _distance3d(a, b) - if not a or not b then return math.huge end - local dx = (a.x or 0) - (b.x or 0) - local dy = (a.y or 0) - (b.y or 0) - local dz = (a.z or 0) - (b.z or 0) - return math.sqrt(dx * dx + dy * dy + dz * dz) -end - -local function _unitHasAttribute(unit, attr) - if not unit or not attr then return false end - local ok, res = pcall(function() return unit:hasAttribute(attr) end) - return ok and res == true -end - -local function _isDcsInfantry(unit) - if not unit then return false end - local tn = string.lower(unit:getTypeName() or '') - if tn:find('infantry') or tn:find('soldier') or tn:find('paratrooper') or tn:find('manpad') then - return true - end - return _unitHasAttribute(unit, 'Infantry') -end - -local function _hasLineOfSight(fromPos, toPos) - if not (fromPos and toPos) then return false end - local p1 = _vec3(fromPos.x, (fromPos.y or 0) + 2.0, fromPos.z) - local p2 = _vec3(toPos.x, (toPos.y or 0) + 2.0, toPos.z) - local ok, visible = pcall(function() return land.isVisible(p1, p2) end) - return ok and visible == true -end - -local function _jtacTargetScore(unit) - if not unit then return -1 end - if _unitHasAttribute(unit, 'SAM SR') or _unitHasAttribute(unit, 'SAM TR') or _unitHasAttribute(unit, 'SAM CC') or _unitHasAttribute(unit, 'SAM LN') then - return 120 - end - if _unitHasAttribute(unit, 'Air Defence') or _unitHasAttribute(unit, 'AAA') then - return 100 - end - if _unitHasAttribute(unit, 'IR Guided SAM') or _unitHasAttribute(unit, 'SAM') then - return 95 - end - if _unitHasAttribute(unit, 'Artillery') or _unitHasAttribute(unit, 'MLRS') then - return 80 - end - if _unitHasAttribute(unit, 'Armor') or _unitHasAttribute(unit, 'Tanks') then - return 70 - end - if _unitHasAttribute(unit, 'APC') or _unitHasAttribute(unit, 'IFV') then - return 60 - end - if _isDcsInfantry(unit) then - return 20 - end - return 40 -end - -local function _jtacTargetScoreProfiled(unit, profile) - -- Base score first - local base = _jtacTargetScore(unit) - local mult = 1.0 - local attribs = { - sam = _unitHasAttribute(unit, 'SAM') or _unitHasAttribute(unit, 'SAM SR') or _unitHasAttribute(unit, 'SAM TR') or _unitHasAttribute(unit, 'SAM LN') or _unitHasAttribute(unit, 'IR Guided SAM'), - aaa = _unitHasAttribute(unit, 'Air Defence') or _unitHasAttribute(unit, 'AAA'), - armor = _unitHasAttribute(unit, 'Armor') or _unitHasAttribute(unit, 'Tanks'), - ifv = _unitHasAttribute(unit, 'APC') or _unitHasAttribute(unit, 'Infantry Fighting Vehicle'), - arty = _unitHasAttribute(unit, 'Artillery') or _unitHasAttribute(unit, 'MLRS'), - inf = _isDcsInfantry(unit) - } - if profile == 'threat' then - if attribs.sam then mult = 1.6 elseif attribs.aaa then mult = 1.4 elseif attribs.armor then mult = 1.25 elseif attribs.ifv then mult = 1.15 elseif attribs.arty then mult = 1.1 elseif attribs.inf then mult = 0.8 end - elseif profile == 'armor' then - if attribs.armor then mult = 1.5 elseif attribs.ifv then mult = 1.3 elseif attribs.sam then mult = 1.25 elseif attribs.aaa then mult = 1.2 elseif attribs.arty then mult = 1.1 elseif attribs.inf then mult = 0.85 end - elseif profile == 'soft' then - if attribs.aaa then mult = 1.5 elseif attribs.arty then mult = 1.4 elseif attribs.inf then mult = 1.2 elseif attribs.ifv then mult = 1.1 elseif attribs.armor then mult = 1.0 elseif attribs.sam then mult = 0.9 end - elseif profile == 'inf_last' then - if attribs.inf then mult = 0.6 end - else - -- balanced; slight bump to SAM/AAA - if attribs.sam then mult = 1.3 elseif attribs.aaa then mult = 1.2 end - end - return math.floor(base * mult + 0.5) -end - -_msgGroup = function(group, text, t) - if not group then return end - MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToGroup(group) -end - -_msgCoalition = function(side, text, t) - MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToCoalition(side) -end - --- ========================= --- Logging Helpers --- ========================= --- Log levels: 0=NONE, 1=ERROR, 2=INFO, 3=VERBOSE, 4=DEBUG -local LOG_NONE = 0 -local LOG_ERROR = 1 -local LOG_INFO = 2 -local LOG_VERBOSE = 3 -local LOG_DEBUG = 4 - -local _logLevelLabels = { - [LOG_ERROR] = 'ERROR', - [LOG_INFO] = 'INFO', - [LOG_VERBOSE] = 'VERBOSE', - [LOG_DEBUG] = 'DEBUG', -} - -_log = function(level, msg) - local logLevel = CTLD.Config and CTLD.Config.LogLevel or LOG_INFO - if level > logLevel or level == LOG_NONE then return end - local label = _logLevelLabels[level] or tostring(level) - local text = string.format('[Moose_CTLD][%s] %s', label, tostring(msg)) - if env and env.info then - env.info(text) - else - print(text) - end -end - -_logError = function(msg) _log(LOG_ERROR, msg) end -_logInfo = function(msg) _log(LOG_INFO, msg) end --- Treat VERBOSE as DEBUG-only to reduce noise unless LogLevel is 4 -_logVerbose = function(msg) _log(LOG_DEBUG, msg) end -_logDebug = function(msg) _log(LOG_DEBUG, msg) end - --- Emits tagged messages regardless of configured LogLevel (used by explicit debug toggles) -_logImmediate = function(tag, msg) - local text = string.format('[Moose_CTLD][%s] %s', tag or 'DEBUG', tostring(msg)) - if env and env.info then - env.info(text) - else - print(text) - end -end - -local function _debugCrateSight(kind, params) - if not params or not params.unit then return end - CTLD._debugSightState = CTLD._debugSightState or {} - local key = string.format('%s:%s', kind, params.unit) - local state = CTLD._debugSightState[key] or {} - local now = params.now or timer.getTime() - local interval = params.interval or 1.0 - local step = params.step or 10.0 - local name = params.name or 'none' - local distance = params.distance or math.huge - local crateCount = params.count or 0 - local troopCount = params.troops or 0 - local shouldLog = false - - if not state.lastTime or interval <= 0 or (now - state.lastTime) >= interval then - shouldLog = true - end - if state.lastName ~= name then - shouldLog = true - end - if state.lastCount ~= crateCount or state.lastTroops ~= troopCount then - shouldLog = true - end - if distance ~= math.huge then - if not state.lastDist or math.abs(distance - state.lastDist) >= step then - shouldLog = true - end - elseif state.lastDist ~= math.huge then - shouldLog = true - end - - if not shouldLog then return end - - local distText = (distance ~= math.huge) and string.format('d=%.1f', distance) or 'd=n/a' - local summaryParts = { string.format('%d crate(s)', crateCount) } - if troopCount and troopCount > 0 then - table.insert(summaryParts, string.format('%d troop group(s)', troopCount)) - end - local summary = table.concat(summaryParts, ', ') - local noteParts = {} - if params.radius then table.insert(noteParts, string.format('radius=%dm', math.floor(params.radius))) end - if params.note then table.insert(noteParts, params.note) end - local noteText = (#noteParts > 0) and (' [' .. table.concat(noteParts, ' ') .. ']') or '' - local targetLabel = params.targetLabel or 'nearest' - local typeHint = params.typeHint and (' type=' .. params.typeHint) or '' - _logImmediate(kind, string.format('Unit %s tracking %s; %s=%s %s%s%s', - params.unit, summary, targetLabel, name, distText, typeHint, noteText)) - - state.lastTime = now - state.lastName = name - state.lastDist = distance - state.lastCount = crateCount - state.lastTroops = troopCount - CTLD._debugSightState[key] = state -end - -function CTLD:_collectEntryUnitTypes(entry) - local collected = {} - local seen = {} - if type(entry) ~= 'table' then return collected end - _addUniqueString(collected, seen, entry.unitType) - if type(entry.unitTypes) == 'table' then - for _,v in ipairs(entry.unitTypes) do - _addUniqueString(collected, seen, v) - end - end - if entry.build then - local fromBuilder = _collectTypesFromBuilder(entry.build) - for _,v in ipairs(fromBuilder) do - _addUniqueString(collected, seen, v) - end - end - return collected -end - -function CTLD:_validateCatalogUnitTypes() - if self._catalogValidated then return end - if self.Config and self.Config.SkipCatalogValidation then return end - - local dbReady, dbReason = _isUnitDatabaseReady() - if not dbReady then - if not self._catalogValidationDebugLogged then - self._catalogValidationDebugLogged = true - local dbRoot = rawget(_G, 'db') - local unitByTypeType = dbRoot and type(dbRoot.unit_by_type) or 'nil' - local unitsType = dbRoot and type(dbRoot.units) or 'nil' - local unitsAltType = dbRoot and type(dbRoot.Units) or 'nil' - local sampleKey = 'Soldier M4' - local sampleValue = (dbRoot and type(dbRoot.unit_by_type) == 'table') and dbRoot.unit_by_type[sampleKey] or nil - _logDebug(string.format('Catalog validation DB probe: reason=%s db=%s unit_by_type=%s units=%s Units=%s sample[%s]=%s', - tostring(dbReason), type(dbRoot), unitByTypeType, unitsType, unitsAltType, sampleKey, tostring(sampleValue))) - end - if dbReason == 'missing' or dbReason == 'no_unit_by_type' then - _logInfo('Catalog validation skipped: DCS mission scripting environment does not expose the global unit database (db/unit_by_type)') - self._catalogValidated = true - return - end - - self._catalogValidationRetries = (self._catalogValidationRetries or 0) + 1 - local retry = self._catalogValidationRetries - local retryLimit = 60 - if retry > retryLimit then - _logError('Catalog validation skipped: DCS unit database not available after repeated attempts') - self._catalogValidated = true - self._catalogValidationScheduled = nil - return - end - if timer and timer.scheduleFunction and timer.getTime then - if not self._catalogValidationScheduled then - self._catalogValidationScheduled = true - local delay = math.min(10, 1 + retry) - local instance = self - timer.scheduleFunction(function() - instance._catalogValidationScheduled = nil - instance._catalogValidated = nil - instance:_validateCatalogUnitTypes() - return nil - end, {}, timer.getTime() + delay) - end - if retry == 1 or (retry % 5 == 0) then - _logInfo(string.format('Catalog validation deferred: DCS unit database not ready yet (retry %d/%d)', retry, retryLimit)) - end - else - if retry == 1 then - _logInfo('Catalog validation deferred: DCS unit database not ready and timer API unavailable') - end - if retry >= 3 then - _logError('Catalog validation skipped: cannot access DCS unit database or schedule retries') - self._catalogValidated = true - end - end - return - end - - if self._catalogValidationRetries and self._catalogValidationRetries > 0 then - _unitTypeCache = {} - end - self._catalogValidationRetries = 0 - - local missing = {} - - local function markMissing(typeName, source) - local key = _trim(typeName) - if not key or key == '' then return end - local list = missing[key] - if not list then - list = {} - missing[key] = list - end - for _,ref in ipairs(list) do - if ref == source then return end - end - list[#list + 1] = source - end - - for key,entry in pairs(self.Config.CrateCatalog or {}) do - local types = self:_collectEntryUnitTypes(entry) - for _,unitType in ipairs(types) do - if not _unitTypeExists(unitType) then - markMissing(unitType, 'crate:'..tostring(key)) - end - end - end - - local troopDefs = (self.Config.Troops and self.Config.Troops.TroopTypes) or {} - for label,def in pairs(troopDefs) do - local function check(list, suffix) - for _,unitType in ipairs(list or {}) do - if not _unitTypeExists(unitType) then - markMissing(unitType, string.format('troop:%s:%s', tostring(label), suffix)) - end - end - end - check(def.unitsBlue, 'blue') - check(def.unitsRed, 'red') - check(def.units, 'fallback') - end - - if next(missing) then - for typeName, sources in pairs(missing) do - _logError(string.format('Catalog validation: unknown unit type "%s" referenced by %s', typeName, table.concat(sources, ', '))) - end - else - _logInfo('Catalog validation: all referenced unit types resolved in DCS database') - end - - self._catalogValidated = true -end - --- ========================= --- Zone and Unit Utilities --- ========================= - -local function _findZone(z) - if z.name then - local mz = ZONE:FindByName(z.name) - if mz then return mz end - end - if z.coord then - local r = z.radius or 150 - -- Create a Vec2 in a way that works even if MOOSE VECTOR2 class isn't available - local function _mkVec2(x, z) - if VECTOR2 and VECTOR2.New then return VECTOR2:New(x, z) end - -- DCS uses Vec2 with fields x and y - return { x = x, y = z } - end - local v = _mkVec2(z.coord.x, z.coord.z) - return ZONE_RADIUS:New(z.name or ('CTLD_ZONE_'..math.random(10000,99999)), v, r) - end - return nil -end - -local function _getUnitType(unit) - local ud = unit and unit:GetDesc() or nil - return ud and ud.typeName or unit and unit:GetTypeName() -end - --- Get aircraft capacity limits for crates and troops --- Returns { maxCrates, maxTroops, maxWeightKg } for the given unit --- Falls back to DefaultCapacity if aircraft type not specifically configured -local function _getAircraftCapacity(unit) - if not unit then - return { - maxCrates = CTLD.Config.DefaultCapacity.maxCrates or 4, - maxTroops = CTLD.Config.DefaultCapacity.maxTroops or 12, - maxWeightKg = CTLD.Config.DefaultCapacity.maxWeightKg or 2000 - } - end - - local unitType = _getUnitType(unit) - local capacities = CTLD.Config.AircraftCapacities or {} - local specific = capacities[unitType] - - if specific then - return { - maxCrates = specific.maxCrates or 0, - maxTroops = specific.maxTroops or 0, - maxWeightKg = specific.maxWeightKg or 0 - } - end - - -- Fallback to defaults - local defaults = CTLD.Config.DefaultCapacity or {} - return { - maxCrates = defaults.maxCrates or 4, - maxTroops = defaults.maxTroops or 12, - maxWeightKg = defaults.maxWeightKg or 2000 - } -end - --- Check if a unit is in the air (flying/hovering, not landed) --- Based on original CTLD logic: uses DCS InAir() API plus velocity threshold --- Returns: true if airborne, false if landed/grounded -local function _isUnitInAir(unit) - if not unit then return false end - - -- First check: DCS API InAir() - if it says we're on ground, trust it - if not unit:InAir() then - return false - end - - -- Second check: velocity threshold (handles edge cases where InAir() is true but we're stationary on ground) - -- Less than 0.05 m/s (~0.1 knots) = essentially stopped = consider landed - -- NOTE: AI can hold perfect hover, so only apply this check for player-controlled units - local vel = unit:GetVelocity() - if vel and unit:GetPlayerName() then - local vx = vel.x or 0 - local vz = vel.z or 0 - local groundSpeed = math.sqrt((vx * vx) + (vz * vz)) -- horizontal speed in m/s - if groundSpeed < 0.05 then - return false -- stopped on ground - end - end - - return true -- airborne -end - --- Get ground speed in m/s for a unit -local function _getGroundSpeed(unit) - if not unit then return 0 end - local vel = unit:GetVelocity() - if not vel or not vel.x or not vel.z then return 0 end - return math.sqrt(vel.x * vel.x + vel.z * vel.z) -end - --- Calculate height above ground level for a unit (meters) -local function _getUnitAGL(unit) - if not unit then return math.huge end - local pos = unit:GetPointVec3() - if not pos then return math.huge end - local terrain = 0 - if land and land.getHeight then - local success, h = pcall(land.getHeight, { x = pos.x, y = pos.z }) - if success and type(h) == 'number' then - terrain = h - end - end - return pos.y - terrain -end - -local function _nearestZonePoint(unit, list) - if not unit or not unit:IsAlive() then return nil end - -- Get unit position using DCS API to avoid dependency on MOOSE point methods - local uname = unit:GetName() - local du = Unit.getByName and Unit.getByName(uname) or nil - if not du or not du:getPoint() then return nil end - local up = du:getPoint() - local ux, uz = up.x, up.z - - local best, bestd = nil, nil - for _, z in ipairs(list or {}) do - local mz = _findZone(z) - local zx, zz - if z and z.name and trigger and trigger.misc and trigger.misc.getZone then - local tz = trigger.misc.getZone(z.name) - if tz and tz.point then zx, zz = tz.point.x, tz.point.z end - end - if (not zx) and mz and mz.GetPointVec3 then - local zp = mz:GetPointVec3() - -- Try to read numeric fields directly to avoid method calls - if zp and type(zp) == 'table' and zp.x and zp.z then zx, zz = zp.x, zp.z end - end - if (not zx) and z and z.coord then - zx, zz = z.coord.x, z.coord.z - end - - if zx and zz then - local dx = (zx - ux) - local dz = (zz - uz) - local d = math.sqrt(dx*dx + dz*dz) - if (not bestd) or d < bestd then best, bestd = mz, d end - end - end - if not best then return nil, nil end - return best, bestd -end - --- Check if a unit is inside a Pickup Zone. Returns (inside:boolean, zone, dist, radius) -function CTLD:_isUnitInsidePickupZone(unit, activeOnly) - if not unit or not unit:IsAlive() then return false, nil, nil, nil end - local zone, dist - if activeOnly then - zone, dist = self:_nearestActivePickupZone(unit) - else - local defs = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {} - zone, dist = _nearestZonePoint(unit, defs) - end - if not zone or not dist then return false, nil, nil, nil end - local r = self:_getZoneRadius(zone) - if not r then return false, zone, dist, nil end - return dist <= r, zone, dist, r -end - --- Helper: get nearest ACTIVE pickup zone (by configured list), respecting CTLD's active flags -function CTLD:_collectActivePickupDefs() - local out = {} - local added = {} -- Track added zone names to prevent duplicates - - -- From config-defined zones - local defs = (self.Config and self.Config.Zones and self.Config.Zones.PickupZones) or {} - for _, z in ipairs(defs) do - local n = z.name - if (not n) or self._ZoneActive.Pickup[n] ~= false then - table.insert(out, z) - if n then added[n] = true end - end - end - - -- From MOOSE zone objects if present (skip if already added from config) - if self.PickupZones and #self.PickupZones > 0 then - for _, mz in ipairs(self.PickupZones) do - if mz and mz.GetName then - local n = mz:GetName() - if self._ZoneActive.Pickup[n] ~= false and not added[n] then - table.insert(out, { name = n }) - added[n] = true - end - end - end - end - return out -end - -function CTLD:_nearestActivePickupZone(unit) - return _nearestZonePoint(unit, self:_collectActivePickupDefs()) -end - -local function _defaultCountryForSide(side) - if not (country and country.id) then return nil end - if side == coalition.side.BLUE then - return country.id.USA or country.id.CJTF_BLUE - elseif side == coalition.side.RED then - return country.id.RUSSIA or country.id.CJTF_RED - elseif side == coalition.side.NEUTRAL then - return country.id.UN or country.id.CJTF_NEUTRAL or country.id.USA - end - return nil -end - -local function _coalitionAddGroup(side, category, groupData, ctldConfig) - -- Enforce side/category in groupData just to be safe - groupData.category = category - local countryId = ctldConfig and ctldConfig.CountryId - if not countryId then - countryId = _defaultCountryForSide(side) - if ctldConfig then ctldConfig.CountryId = countryId end - end - if countryId then - groupData.country = countryId - end - - -- Apply air-spawn altitude adjustment for AIRPLANE category if DroneAirSpawn is enabled - if category == Group.Category.AIRPLANE and ctldConfig and ctldConfig.DroneAirSpawn and ctldConfig.DroneAirSpawn.Enabled then - if groupData.units and #groupData.units > 0 then - local altAGL = ctldConfig.DroneAirSpawn.AltitudeMeters or 3048 - local speed = ctldConfig.DroneAirSpawn.SpeedMps or 120 - - for _, unit in ipairs(groupData.units) do - -- Get terrain height at spawn location - local terrainHeight = land.getHeight({x = unit.x, y = unit.y}) - -- Set altitude ASL (Above Sea Level) - unit.alt = terrainHeight + altAGL - unit.speed = speed - -- Ensure unit has appropriate spawn type set - unit.alt_type = "BARO" -- Barometric altitude - end - end - end - - local addCountry = countryId or side - return coalition.addGroup(addCountry, category, groupData) -end - -local function _spawnStaticCargo(side, point, cargoType, name) - local static = { - name = name, - type = cargoType, - x = point.x, - y = point.z, - heading = 0, - canCargo = true, - } - return coalition.addStaticObject(side, static) -end - -local function _vec3FromUnit(unit) - local p = unit:GetPointVec3() - return { x = p.x, y = p.y, z = p.z } -end - --- Update DCS internal cargo weight based on loaded crates and troops --- This affects aircraft performance (hover, fuel consumption, speed, etc.) -local function _updateCargoWeight(group) - if not group then return end - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local gname = group:GetName() - local totalWeight = 0 - - -- Add weight from loaded crates - local crateData = CTLD._loadedCrates[gname] - if crateData and crateData.totalWeightKg then - totalWeight = totalWeight + crateData.totalWeightKg - end - - -- Add weight from loaded troops - local troopData = CTLD._troopsLoaded[gname] - if troopData and troopData.weightKg then - totalWeight = totalWeight + troopData.weightKg - end - - -- Call DCS API to set internal cargo weight (affects flight model) - local unitName = unit:GetName() - if unitName and trigger and trigger.action and trigger.action.setUnitInternalCargo then - pcall(function() - trigger.action.setUnitInternalCargo(unitName, totalWeight) - end) - end -end - --- Unique id generator for map markups (lines/circles/text) -local function _nextMarkupId() - CTLD._NextMarkupId = (CTLD._NextMarkupId or 10000) + 1 - return CTLD._NextMarkupId -end - -local function _spawnCrateSmoke(position, color, config, crateId) - if not position or not color then return end - - -- Parse config with defaults - local enabled = true - local autoRefresh = false - local refreshInterval = 240 - local maxRefreshDuration = 600 - local offsetMeters = 5 - local offsetRandom = true - local offsetVertical = 2 - - if config then - enabled = (config.Enabled ~= false) -- default true - autoRefresh = (config.AutoRefresh == true) - refreshInterval = tonumber(config.RefreshInterval) or 240 - maxRefreshDuration = tonumber(config.MaxRefreshDuration) or 600 - offsetMeters = tonumber(config.OffsetMeters) or 5 - offsetRandom = (config.OffsetRandom ~= false) -- default true - offsetVertical = tonumber(config.OffsetVertical) or 2 - end - - if not enabled then return end - - -- Compute ground-adjusted position with offsets - local sx, sz = position.x, position.z - local sy = position.y or 0 - if sy == 0 and land and land.getHeight then - local ok, h = pcall(land.getHeight, { x = sx, y = sz }) - if ok and type(h) == 'number' then sy = h end - end - - -- Apply lateral and vertical offsets - local ox, oz = 0, 0 - if offsetMeters > 0 then - local angle = offsetRandom and (math.random() * 2 * math.pi) or 0 - ox = offsetMeters * math.cos(angle) - oz = offsetMeters * math.sin(angle) - end - local smokePos = { x = sx + ox, y = sy + offsetVertical, z = sz + oz } - - -- Emit smoke now - local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) - if coord and coord.Smoke then - if color == trigger.smokeColor.Green then - coord:SmokeGreen() - elseif color == trigger.smokeColor.Red then - coord:SmokeRed() - elseif color == trigger.smokeColor.White then - coord:SmokeWhite() - elseif color == trigger.smokeColor.Orange then - coord:SmokeOrange() - elseif color == trigger.smokeColor.Blue then - coord:SmokeBlue() - else - coord:SmokeGreen() - end - else - trigger.action.smoke(smokePos, color) - end - - -- Record smoke meta for global refresh loop instead of per-crate timer - if autoRefresh and crateId and refreshInterval > 0 and maxRefreshDuration > 0 then - CTLD._crates = CTLD._crates or {} - local meta = CTLD._crates[crateId] - if meta then - meta._smoke = meta._smoke or {} - if not meta._smoke.enabled then - meta._smoke.enabled = true - end - meta._smoke.auto = true - meta._smoke.startTime = timer.getTime() - meta._smoke.nextTime = timer.getTime() + refreshInterval - meta._smoke.interval = refreshInterval - meta._smoke.maxDuration = maxRefreshDuration - meta._smoke.color = color - meta._smoke.offsetMeters = offsetMeters - meta._smoke.offsetRandom = offsetRandom - meta._smoke.offsetVertical = offsetVertical - - -- Ensure background ticker(s) are running - if CTLD._ensureBackgroundTasks then - CTLD:_ensureBackgroundTasks() - end - end - end -end - --- Clean up smoke refresh schedule for a crate -local function _cleanupCrateSmoke(crateId) - if not crateId then return end - -- Clear legacy per-crate schedule if present - CTLD._smokeRefreshSchedules = CTLD._smokeRefreshSchedules or {} - if CTLD._smokeRefreshSchedules[crateId] then - if CTLD._smokeRefreshSchedules[crateId].funcId then - pcall(timer.removeFunction, CTLD._smokeRefreshSchedules[crateId].funcId) - end - CTLD._smokeRefreshSchedules[crateId] = nil - end - -- Clear new smoke meta so the global loop stops refreshing - if CTLD._crates and CTLD._crates[crateId] then - CTLD._crates[crateId]._smoke = nil - end -end - --- Central schedule registry helpers -function CTLD:_registerSchedule(key, funcId) - self._schedules = self._schedules or {} - if self._schedules[key] then - pcall(timer.removeFunction, self._schedules[key]) - end - self._schedules[key] = funcId -end - -function CTLD:_cancelSchedule(key) - if self._schedules and self._schedules[key] then - pcall(timer.removeFunction, self._schedules[key]) - self._schedules[key] = nil - end -end - --- Track one-shot timers for cleanup -local function _trackOneShotTimer(id) - if id and CTLD._pendingTimers then - CTLD._pendingTimers[id] = true - end - return id -end - --- Clean up one-shot timers when they execute -local function _wrapOneShotCallback(callback) - return function(...) - local result = callback(...) - -- If callback returns a time, it's recurring - don't remove - if not result or type(result) ~= 'number' then - local trackedId = nil - for id, _ in pairs(CTLD._pendingTimers or {}) do - if id == callback then - trackedId = id - break - end - end - if trackedId and CTLD._pendingTimers then - CTLD._pendingTimers[trackedId] = nil - end - end - return result - end -end - -local function _removeMenuHandle(menu) - if not menu or type(menu) ~= 'table' then return end - - local function _menuIsRegistered(m) - if not MENU_INDEX then return true end - if not m.Group or not m.MenuText then return true end - local okPath, path = pcall(function() - return MENU_INDEX:ParentPath(m.ParentMenu, m.MenuText) - end) - if not okPath or not path then - return false - end - local okHas, registered = pcall(function() - return MENU_INDEX:HasGroupMenu(m.Group, path) - end) - if not okHas then - return false - end - return registered == m - end - - if menu.Remove and _menuIsRegistered(menu) then - local ok, err = pcall(function() menu:Remove() end) - if not ok then - _logVerbose(string.format('[MenuCleanup] Failed to remove menu %s: %s', tostring(menu.MenuText), tostring(err))) - end - end - - if menu.Destroy then pcall(function() menu:Destroy() end) end - if menu.Delete then pcall(function() menu:Delete() end) end -end - -local function _countTableEntries(t) - if not t then return 0 end - local n = 0 - for _ in pairs(t) do n = n + 1 end - return n -end - -local function _cleanupGroupMenus(ctld, groupName) - if not (ctld and groupName) then return end - if ctld.MenusByGroup and ctld.MenusByGroup[groupName] then - _removeMenuHandle(ctld.MenusByGroup[groupName]) - ctld.MenusByGroup[groupName] = nil - end - if CTLD._inStockMenus and CTLD._inStockMenus[groupName] then - for _, menu in pairs(CTLD._inStockMenus[groupName]) do - _removeMenuHandle(menu) - end - CTLD._inStockMenus[groupName] = nil - end -end - -local function _clearPerGroupCaches(groupName) - if not groupName then return end - local caches = { - CTLD._troopsLoaded, - CTLD._loadedCrates, - CTLD._loadedTroopTypes, - CTLD._deployedTroops, - CTLD._buildConfirm, - CTLD._buildCooldown, - CTLD._medevacUnloadStates, - CTLD._medevacLoadStates, - CTLD._medevacEnrouteStates, - CTLD._coachOverride, - } - for _, tbl in ipairs(caches) do - if tbl then tbl[groupName] = nil end - end - if CTLD._msgState then - CTLD._msgState['GRP:'..groupName] = nil - end - - -- Note: One-shot timers are now self-cleaning via wrapper, but we log for visibility - _logVerbose(string.format('[CTLD] Cleared caches for group %s', groupName)) -end - -local function _clearPerUnitCachesForGroup(group) - if not group then return end - local units - local ok, res = pcall(function() return group:GetUnits() end) - if ok and type(res) == 'table' then - units = res - else - local okUnit, unit = pcall(function() return group:GetUnit(1) end) - if okUnit and unit then units = { unit } end - end - if not units then return end - for _, unit in ipairs(units) do - if unit then - local uname = unit.GetName and unit:GetName() - if uname then - if CTLD._hoverState then CTLD._hoverState[uname] = nil end - if CTLD._unitLast then CTLD._unitLast[uname] = nil end - if CTLD._coachState then CTLD._coachState[uname] = nil end - if CTLD._groundLoadState then CTLD._groundLoadState[uname] = nil end - end - end - end -end - -local function _groupHasAliveTransport(group, allowedTypes) - if not (group and allowedTypes) then return false end - local units - local ok, res = pcall(function() return group:GetUnits() end) - if ok and type(res) == 'table' then - units = res - else - local okUnit, unit = pcall(function() return group:GetUnit(1) end) - if okUnit and unit then units = { unit } end - end - if not units then return false end - for _, unit in ipairs(units) do - if unit and unit.IsAlive and unit:IsAlive() then - local typ = _getUnitType(unit) - if typ and _isIn(allowedTypes, typ) then - return true - end - end - end - return false -end - -function CTLD:_cleanupTransportGroup(group, groupName) - local gname = groupName - if not gname and group and group.GetName then - gname = group:GetName() - end - if not gname then return end - - _cleanupGroupMenus(self, gname) - _clearPerGroupCaches(gname) - - if group then - _clearPerUnitCachesForGroup(group) - else - local mooseGroup = nil - if GROUP and GROUP.FindByName then - local ok, res = pcall(function() return GROUP:FindByName(gname) end) - if ok then mooseGroup = res end - end - if mooseGroup then _clearPerUnitCachesForGroup(mooseGroup) end - end - - -- Cleanup JTAC registry if this group had JTAC registered - if self._jtacRegistry and self._jtacRegistry[gname] then - self:_cleanupJTACEntry(gname) - end - - _logDebug(string.format('[MenuCleanup] Cleared CTLD state for group %s', gname)) -end - -function CTLD:_removeDynamicSalvageZone(zoneName, reason) - if not zoneName then return end - - if self.SalvageDropZones then - for idx = #self.SalvageDropZones, 1, -1 do - local zone = self.SalvageDropZones[idx] - if zone and zone.GetName and zone:GetName() == zoneName then - table.remove(self.SalvageDropZones, idx) - end - end - end - - if self._ZoneDefs and self._ZoneDefs.SalvageDropZones then - self._ZoneDefs.SalvageDropZones[zoneName] = nil - end - - if self._ZoneActive and self._ZoneActive.SalvageDrop then - self._ZoneActive.SalvageDrop[zoneName] = nil - end - - if self._DynamicSalvageZones then - self._DynamicSalvageZones[zoneName] = nil - end - - if self._DynamicSalvageQueue then - for idx = #self._DynamicSalvageQueue, 1, -1 do - if self._DynamicSalvageQueue[idx] == zoneName then - table.remove(self._DynamicSalvageQueue, idx) - end - end - end - - self:_removeZoneDrawing('SalvageDrop', zoneName) - _logInfo(string.format('[SlingLoadSalvage] Removed dynamic salvage zone %s (%s)', zoneName, reason or 'cleanup')) - local ok, err = pcall(function() self:DrawZonesOnMap() end) - if not ok then - _logError(string.format('[SlingLoadSalvage] DrawZonesOnMap failed after removing %s: %s', zoneName, tostring(err))) - end -end - -function CTLD:_enforceDynamicSalvageZoneLimit() - local cfg = self.Config and self.Config.SlingLoadSalvage or nil - if not cfg or not cfg.Enabled then return end - - local zones = self._DynamicSalvageZones - if not zones then return end - - local lifetime = tonumber(cfg.DynamicZoneLifetime or 0) or 0 - local maxZones = tonumber(cfg.MaxDynamicZones or 0) or 0 - local now = timer and timer.getTime and timer.getTime() or 0 - - if lifetime > 0 then - local expired = {} - for name, meta in pairs(zones) do - if meta then - local expiresAt = meta.expiresAt - if not expiresAt and meta.createdAt then - expiresAt = meta.createdAt + lifetime - end - if expiresAt and now >= expiresAt then - table.insert(expired, name) - end - end - end - for _, zname in ipairs(expired) do - self:_removeDynamicSalvageZone(zname, 'expired') - end - end - - if maxZones > 0 and self._DynamicSalvageQueue then - while #self._DynamicSalvageQueue > maxZones do - local oldest = table.remove(self._DynamicSalvageQueue, 1) - if oldest and zones[oldest] then - self:_removeDynamicSalvageZone(oldest, 'max-cap') - end - end - end -end - --- Global smoke refresh ticker (single loop for all crates) -function CTLD:_ensureGlobalSmokeTicker() - if self._schedules and self._schedules.smokeTicker then return end - - local function tick() - local now = timer.getTime() - if CTLD and CTLD._crates then - for name, meta in pairs(CTLD._crates) do - if meta and meta._smoke and meta._smoke.auto and meta.point then - local s = meta._smoke - if (now - (s.startTime or now)) > (s.maxDuration or 0) then - meta._smoke = nil - elseif now >= (s.nextTime or 0) then - -- Spawn another puff - local pos = { x = meta.point.x, y = 0, z = meta.point.z } - if land and land.getHeight then - local ok, h = pcall(land.getHeight, { x = pos.x, y = pos.z }) - if ok and type(h) == 'number' then pos.y = h end - end - _spawnCrateSmoke(pos, s.color or trigger.smokeColor.Green, { - Enabled = true, - AutoRefresh = false, -- avoid recursion; we manage nextTime here - OffsetMeters = s.offsetMeters or 0, - OffsetRandom = s.offsetRandom ~= false, - OffsetVertical = s.offsetVertical or 0, - }, name) - s.nextTime = now + (s.interval or 240) - end - end - end - end - return timer.getTime() + 10 -- tick every 10s - end - - local id = timer.scheduleFunction(tick, nil, timer.getTime() + 10) - self:_registerSchedule('smokeTicker', id) -end - --- Periodic GC to prune stale messaging/coach entries and smoke meta -function CTLD:_ensurePeriodicGC() - if self._schedules and self._schedules.periodicGC then return end - - local function gcTick() - local now = timer.getTime() - - -- Coach state: remove units that no longer exist - if CTLD and CTLD._coachState then - for uname, _ in pairs(CTLD._coachState) do - local u = Unit.getByName(uname) - if not u then CTLD._coachState[uname] = nil end - end - end - - -- Message throttle state: remove dead/missing groups - if CTLD and CTLD._msgState then - for scope, _ in pairs(CTLD._msgState) do - local gname = string.match(scope, '^GRP:(.+)$') - if gname then - local g = Group.getByName(gname) - if not g then CTLD._msgState[scope] = nil end - end - end - end - - -- Smoke meta: prune crates without points or exceeded duration - if CTLD and CTLD._crates then - for name, meta in pairs(CTLD._crates) do - if meta and meta._smoke then - local s = meta._smoke - if (not meta.point) or ((now - (s.startTime or now)) > (s.maxDuration or 0)) then - meta._smoke = nil - end - end - end - end - - -- Enforce MEDEVAC crew limit (keep last 100 requests) - if CTLD and CTLD._medevacCrews then - local crewCount = 0 - local crewList = {} - for crewName, crewData in pairs(CTLD._medevacCrews) do - crewCount = crewCount + 1 - table.insert(crewList, { name = crewName, time = crewData.requestTime or 0 }) - end - if crewCount > 100 then - table.sort(crewList, function(a, b) return a.time < b.time end) - local toRemove = crewCount - 100 - for i = 1, toRemove do - local crewName = crewList[i].name - local crewData = CTLD._medevacCrews[crewName] - if crewData and crewData.markerID then - pcall(function() trigger.action.removeMark(crewData.markerID) end) - end - CTLD._medevacCrews[crewName] = nil - end - env.info(string.format('[CTLD][GC] Pruned %d old MEDEVAC crew entries', toRemove)) - end - end - - -- Enforce salvage crate limit (keep last 50 active crates per side) - if CTLD and CTLD._salvageCrates then - local crateBySide = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {} } - for crateName, crateData in pairs(CTLD._salvageCrates) do - if crateData.side then - table.insert(crateBySide[crateData.side], { name = crateName, time = crateData.spawnTime or 0 }) - end - end - for side, crates in pairs(crateBySide) do - if #crates > 50 then - table.sort(crates, function(a, b) return a.time < b.time end) - local toRemove = #crates - 50 - for i = 1, toRemove do - local crateName = crates[i].name - local crateData = CTLD._salvageCrates[crateName] - if crateData and crateData.staticObject and crateData.staticObject.destroy then - pcall(function() crateData.staticObject:destroy() end) - end - CTLD._salvageCrates[crateName] = nil - end - local sideName = (side == coalition.side.BLUE and 'BLUE') or 'RED' - env.info(string.format('[CTLD][GC] Pruned %d old %s salvage crates', toRemove, sideName)) - end - end - end - - -- Clean up stale pending timer references - if CTLD and CTLD._pendingTimers then - local timerCount = 0 - for _ in pairs(CTLD._pendingTimers) do - timerCount = timerCount + 1 - end - if timerCount > 200 then - -- If we have too many pending timers, clear old ones (they may have fired already) - env.info(string.format('[CTLD][GC] Clearing %d stale timer references', timerCount)) - CTLD._pendingTimers = {} - end - end - - return timer.getTime() + 300 -- every 5 minutes - end - - local id = timer.scheduleFunction(gcTick, nil, timer.getTime() + 300) - self:_registerSchedule('periodicGC', id) -end - -function CTLD:_ensureBackgroundTasks() - if self._bgStarted then return end - self._bgStarted = true - self:_ensureGlobalSmokeTicker() - self:_ensurePeriodicGC() - self:_ensureAdaptiveBackgroundLoop() - self:_ensureSlingLoadEventHandler() - self:_startHourlyDiagnostics() -end - -function CTLD:_startHoverScheduler() - local coachCfg = CTLD.HoverCoachConfig or {} - if not coachCfg.enabled or self.HoverSched then return end - local interval = coachCfg.interval or 0.75 - local startDelay = coachCfg.startDelay or interval - self.HoverSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() self:ScanHoverPickup() end) - if not ok then _logError('HoverSched ScanHoverPickup error: '..tostring(err)) end - end, {}, startDelay, interval) -end - -function CTLD:_startGroundLoadScheduler() - local groundCfg = CTLD.GroundAutoLoadConfig or {} - if not groundCfg.Enabled or self.GroundLoadSched then return end - local interval = 1.0 -- check every second for ground load conditions - self.GroundLoadSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() self:ScanGroundAutoLoad() end) - if not ok then _logError('GroundLoadSched ScanGroundAutoLoad error: '..tostring(err)) end - end, {}, interval, interval) -end - --- Adaptive background loop consolidating salvage checks and periodic pruning -function CTLD:_ensureAdaptiveBackgroundLoop() - if self._schedules and self._schedules.backgroundLoop then return end - - local function backgroundTick() - local now = timer.getTime() - - -- Salvage crate housekeeping - local cfg = self.Config.SlingLoadSalvage - local activeCrates = 0 - if cfg and cfg.Enabled then - for _, meta in pairs(CTLD._salvageCrates) do - if meta and meta.side == self.Side then - activeCrates = activeCrates + 1 - end - end - - -- Run the standard checker to handle delivery/expiration - local ok, err = pcall(function() self:_CheckSlingLoadSalvageCrates() end) - if not ok then - _logError('[SlingLoadSalvage] backgroundTick error: '..tostring(err)) - end - - -- Stale crate cleanup: destroy any crate that lost its static object reference - for cname, meta in pairs(CTLD._salvageCrates) do - if meta and (not meta.staticObject or (meta.staticObject.destroy and not meta.staticObject:isExist())) then - CTLD._salvageCrates[cname] = nil - end - end - - -- Dynamic salvage zone lifetime enforcement - self:_enforceDynamicSalvageZoneLimit() - end - - -- Hover coach cleanup: remove entries for missing units - if CTLD._coachState then - for uname, _ in pairs(CTLD._coachState) do - if not Unit.getByName(uname) then - CTLD._coachState[uname] = nil - end - end - end - - -- Ensure the hover scan scheduler matches active transport presence - local hasTransports = false - for gname, _ in pairs(self.MenusByGroup or {}) do - local grp = nil - if GROUP and GROUP.FindByName then - local ok, res = pcall(function() return GROUP:FindByName(gname) end) - if ok then grp = res end - end - if grp and grp:IsAlive() then - hasTransports = true - break - end - if not grp and Group and Group.getByName then - local ok, dcsGrp = pcall(function() return Group.getByName(gname) end) - if ok and dcsGrp and dcsGrp:isExist() then - hasTransports = true - break - end - end - end - - if hasTransports then - if (not self.HoverSched) and self._startHoverScheduler then - pcall(function() self:_startHoverScheduler() end) - end - else - if self.HoverSched and self.HoverSched.Stop then - pcall(function() self.HoverSched:Stop() end) - end - self.HoverSched = nil - end - - -- Determine next wake interval based on active salvage crates - local baseInterval = (cfg and cfg.DetectionInterval) or 5 - local adaptive = (cfg and cfg.AdaptiveIntervals) or {} - local nextInterval - if activeCrates == 0 then - nextInterval = adaptive.idle or math.max(baseInterval * 2, 10) - elseif activeCrates <= 20 then - nextInterval = adaptive.low or math.max(baseInterval * 2, 10) - elseif activeCrates <= 35 then - nextInterval = adaptive.medium or math.max(baseInterval * 3, 15) - else - nextInterval = adaptive.high or math.max(baseInterval * 4, 20) - end - nextInterval = math.min(nextInterval, 30) - - CTLD._lastSalvageInterval = nextInterval - - return now + nextInterval - end - - local id = timer.scheduleFunction(function() - local nextTime = backgroundTick() - return nextTime - end, nil, timer.getTime() + 2) - self:_registerSchedule('backgroundLoop', id) -end - -function CTLD:_ensureSlingLoadEventHandler() - -- No event handler needed - we use inAir() checks like original CTLD.lua - if self._slingHandlerRegistered then return end - self._slingHandlerRegistered = true -end - -function CTLD:_startHourlyDiagnostics() - if not (timer and timer.scheduleFunction) then return end - if self._schedules and self._schedules.hourlyDiagnostics then return end - - local function diagTick() - local salvageCount = 0 - for _, _ in ipairs(self.SalvageDropZones or {}) do salvageCount = salvageCount + 1 end - local menuCount = _countTableEntries(self.MenusByGroup) - local dynamicCount = _countTableEntries(self._DynamicSalvageZones) - local sideLabel = (self.Side == coalition.side.BLUE and 'BLUE') - or (self.Side == coalition.side.RED and 'RED') - or (self.Side == coalition.side.NEUTRAL and 'NEUTRAL') - or tostring(self.Side) - - -- Comprehensive memory usage stats - local cratesCount = _countTableEntries(CTLD._crates) - local medevacCrewsCount = _countTableEntries(CTLD._medevacCrews) - local salvageCratesCount = _countTableEntries(CTLD._salvageCrates) - local coachStateCount = _countTableEntries(CTLD._coachState) - local hoverStateCount = _countTableEntries(CTLD._hoverState) - local pendingTimersCount = _countTableEntries(CTLD._pendingTimers) - local msgStateCount = _countTableEntries(CTLD._msgState) - - env.info(string.format('[CTLD][SoakTest][%s] salvageZones=%d dynamicZones=%d menus=%d', - sideLabel, salvageCount, dynamicCount, menuCount)) - env.info(string.format('[CTLD][Memory][%s] crates=%d medevacCrews=%d salvageCrates=%d coachState=%d hoverState=%d pendingTimers=%d msgState=%d', - sideLabel, cratesCount, medevacCrewsCount, salvageCratesCount, coachStateCount, hoverStateCount, pendingTimersCount, msgStateCount)) - - return timer.getTime() + 3600 - end - - local id = timer.scheduleFunction(function() - return diagTick() - end, nil, timer.getTime() + 3600) - self:_registerSchedule('hourlyDiagnostics', id) -end - -local function _ctldHelperGet() - local ctldClass = _G._MOOSE_CTLD or CTLD - if not ctldClass then - env.info('[CTLD] Runtime helper unavailable: CTLD not loaded') - return nil - end - return ctldClass -end - -local function _ctldSideName(side) - if side == coalition.side.BLUE then return 'BLUE' end - if side == coalition.side.RED then return 'RED' end - if side == coalition.side.NEUTRAL then return 'NEUTRAL' end - return tostring(side or 'nil') -end - -local function _ctldFormatSeconds(secs) - if not secs or secs <= 0 then return '0s' end - local minutes = math.floor(secs / 60) - local seconds = math.floor(secs % 60) - if minutes > 0 then - return string.format('%dm%02ds', minutes, seconds) - end - return string.format('%ds', seconds) -end - -if not _G.CTLD_DumpRuntimeStats then - function _G.CTLD_DumpRuntimeStats() - local ctldClass = _ctldHelperGet() - if not ctldClass then return end - - local now = timer and timer.getTime and timer.getTime() or 0 - local salvageBySide = {} - local oldestBySide = {} - for name, meta in pairs(ctldClass._salvageCrates or {}) do - if meta and meta.side then - salvageBySide[meta.side] = (salvageBySide[meta.side] or 0) + 1 - if meta.spawnTime then - local age = now - meta.spawnTime - if age > (oldestBySide[meta.side] or 0) then - oldestBySide[meta.side] = age - end - end - end - end - - local medevacCount = 0 - for _ in pairs(ctldClass._medevacCrews or {}) do - medevacCount = medevacCount + 1 - end - - env.info(string.format('[CTLD] Active salvage crates: BLUE=%d RED=%d', - salvageBySide[coalition.side.BLUE] or 0, - salvageBySide[coalition.side.RED] or 0)) - env.info(string.format('[CTLD] Oldest salvage crate age: BLUE=%s RED=%s', - _ctldFormatSeconds(oldestBySide[coalition.side.BLUE] or 0), - _ctldFormatSeconds(oldestBySide[coalition.side.RED] or 0))) - env.info(string.format('[CTLD] Active MEDEVAC crews: %d', medevacCount)) - - local totalSchedules = 0 - local instanceCount = 0 - for _, inst in ipairs(ctldClass._instances or {}) do - instanceCount = instanceCount + 1 - if inst._schedules then - for _ in pairs(inst._schedules) do - totalSchedules = totalSchedules + 1 - end - end - for key, value in pairs(inst) do - if type(value) == 'table' and value.Stop and key:match('Sched') then - totalSchedules = totalSchedules + 1 - end - end - end - - env.info(string.format('[CTLD] Instances: %d, scheduler objects (approx): %d', instanceCount, totalSchedules)) - env.info(string.format('[CTLD] Last salvage loop interval: %s', _ctldFormatSeconds(ctldClass._lastSalvageInterval or 0))) - end -end - -if not _G.CTLD_DumpCrateAges then - function _G.CTLD_DumpCrateAges() - local ctldClass = _ctldHelperGet() - if not ctldClass then return end - - local crates = {} - local now = timer and timer.getTime and timer.getTime() or 0 - local lifetime = (ctldClass.Config and ctldClass.Config.SlingLoadSalvage and ctldClass.Config.SlingLoadSalvage.CrateLifetime) or 0 - for name, meta in pairs(ctldClass._salvageCrates or {}) do - local age = meta.spawnTime and (now - meta.spawnTime) or 0 - table.insert(crates, { - name = name, - side = meta.side, - age = age, - remaining = math.max(0, lifetime - age), - weight = meta.weight or 0, - }) - end - - table.sort(crates, function(a, b) return a.age > b.age end) - - env.info(string.format('[CTLD] Listing %d salvage crates (lifetime=%ss)', #crates, tostring(lifetime))) - for i = 1, math.min(#crates, 25) do - local c = crates[i] - env.info(string.format('[CTLD] #%02d %s side=%s weight=%dkg age=%s remaining=%s', - i, c.name, _ctldSideName(c.side), c.weight, - _ctldFormatSeconds(c.age), _ctldFormatSeconds(c.remaining))) - end - end -end - -if not _G.CTLD_ListSchedules then - function _G.CTLD_ListSchedules() - local ctldClass = _ctldHelperGet() - if not ctldClass then return end - - local instances = ctldClass._instances or {} - if #instances == 0 then - env.info('[CTLD] No CTLD instances registered') - return - end - - for idx, inst in ipairs(instances) do - local sideLabel = _ctldSideName(inst.Side) - env.info(string.format('[CTLD] Instance #%d side=%s', idx, sideLabel)) - - if inst._schedules then - for key, funcId in pairs(inst._schedules) do - env.info(string.format(' [timer] key=%s funcId=%s', tostring(key), tostring(funcId))) - end - end - - for key, value in pairs(inst) do - if type(value) == 'table' and value.Stop and key:match('Sched') then - local running = 'unknown' - if type(value.IsRunning) == 'function' then - local ok, res = pcall(value.IsRunning, value) - if ok then running = tostring(res) end - end - env.info(string.format(' [sched] key=%s running=%s', tostring(key), running)) - end - end - end - end -end - --- Spawn smoke for MEDEVAC crews with offset system --- position: {x, y, z} table --- smokeColor: trigger.smokeColor enum value --- config: MEDEVAC config table (for offset settings) -local function _spawnMEDEVACSmoke(position, smokeColor, config) - if not position or not smokeColor then return end - - -- Apply smoke offset system - local smokePos = { - x = position.x, - y = land.getHeight({x = position.x, y = position.z}), - z = position.z - } - - local offsetMeters = (config and config.SmokeOffsetMeters) or 5 - local offsetRandom = (not config or config.SmokeOffsetRandom ~= false) -- default true - local offsetVertical = (config and config.SmokeOffsetVertical) or 2 - - if offsetMeters > 0 then - local angle = 0 -- North by default - if offsetRandom then - angle = math.random() * 2 * math.pi -- Random direction - end - smokePos.x = smokePos.x + offsetMeters * math.cos(angle) - smokePos.z = smokePos.z + offsetMeters * math.sin(angle) - end - smokePos.y = smokePos.y + offsetVertical - - -- Spawn smoke using MOOSE COORDINATE (better appearance) or fallback to trigger.action.smoke - local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) - if coord and coord.Smoke then - if smokeColor == trigger.smokeColor.Green then - coord:SmokeGreen() - elseif smokeColor == trigger.smokeColor.Red then - coord:SmokeRed() - elseif smokeColor == trigger.smokeColor.White then - coord:SmokeWhite() - elseif smokeColor == trigger.smokeColor.Orange then - coord:SmokeOrange() - elseif smokeColor == trigger.smokeColor.Blue then - coord:SmokeBlue() - else - coord:SmokeRed() -- default - end - else - trigger.action.smoke(smokePos, smokeColor) - end -end - --- Resolve a zone's center (vec3) and radius (meters). --- Accepts a MOOSE ZONE object returned by _findZone/ZONE:FindByName/ZONE_RADIUS:New -function CTLD:_getZoneCenterAndRadius(mz) - if not mz then return nil, nil end - local name = mz.GetName and mz:GetName() or nil - -- Prefer Mission Editor zone data if available - if name and trigger and trigger.misc and trigger.misc.getZone then - local z = trigger.misc.getZone(name) - if z and z.point and z.radius then - local p = { x = z.point.x, y = z.point.y or 0, z = z.point.z } - return p, z.radius - end - end - -- Fall back to MOOSE zone center - local pv = mz.GetPointVec3 and mz:GetPointVec3() or nil - local p = pv and { x = pv.x, y = pv.y or 0, z = pv.z } or nil - -- Try to fetch a configured radius from our zone defs - local r - if name and self._ZoneDefs then - local d = self._ZoneDefs.PickupZones and self._ZoneDefs.PickupZones[name] - or self._ZoneDefs.DropZones and self._ZoneDefs.DropZones[name] - or self._ZoneDefs.FOBZones and self._ZoneDefs.FOBZones[name] - or self._ZoneDefs.MASHZones and self._ZoneDefs.MASHZones[name] - if d and d.radius then r = d.radius end - end - r = r or (mz.GetRadius and mz:GetRadius()) or 150 - return p, r -end - --- Draw a circle and label for a zone on the F10 map for this coalition. --- kind: 'Pickup' | 'Drop' | 'FOB' | 'MASH' -function CTLD:_drawZoneCircleAndLabel(kind, mz, opts) - if not (trigger and trigger.action and trigger.action.circleToAll and trigger.action.textToAll) then return end - opts = opts or {} - local p, r = self:_getZoneCenterAndRadius(mz) - if not p or not r then return end - local side = (opts.ForAll and -1) or self.Side - local outline = opts.OutlineColor or {0,1,0,0.85} - local fill = opts.FillColor or {0,1,0,0.15} - local lineType = opts.LineType or 1 - local readOnly = (opts.ReadOnly ~= false) - local fontSize = opts.FontSize or 18 - local labelPrefix = opts.LabelPrefix or 'Zone' - local zname = (mz.GetName and mz:GetName()) or '(zone)' - local circleId = _nextMarkupId() - local textId = _nextMarkupId() - trigger.action.circleToAll(side, circleId, p, r, outline, fill, lineType, readOnly, "") - local label = string.format('%s: %s', labelPrefix, zname) - -- Place label centered above the circle (12 o'clock). Horizontal nudge via LabelOffsetX. - -- Simple formula: extra offset from edge = r * ratio + fromEdge - local extra = (r * (opts.LabelOffsetRatio or 0.0)) + (opts.LabelOffsetFromEdge or 30) - local nx = p.x + (opts.LabelOffsetX or 0) - local nz = p.z - (r + (extra or 0)) - local textPos = { x = nx, y = 0, z = nz } - trigger.action.textToAll(side, textId, textPos, {1,1,1,0.9}, {0,0,0,0}, fontSize, readOnly, label) - -- Track ids so they can be cleared later - self._MapMarkup = self._MapMarkup or { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } - self._MapMarkup[kind] = self._MapMarkup[kind] or {} - self._MapMarkup[kind][zname] = { circle = circleId, text = textId } -end - -function CTLD:ClearMapDrawings() - if not (self._MapMarkup and trigger and trigger.action and trigger.action.removeMark) then return end - for _, byName in pairs(self._MapMarkup) do - for _, ids in pairs(byName) do - if ids.circle then pcall(trigger.action.removeMark, ids.circle) end - if ids.text then pcall(trigger.action.removeMark, ids.text) end - end - end - self._MapMarkup = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } -end - -function CTLD:_removeZoneDrawing(kind, zname) - if not (self._MapMarkup and self._MapMarkup[kind] and self._MapMarkup[kind][zname]) then return end - local ids = self._MapMarkup[kind][zname] - if ids.circle then pcall(trigger.action.removeMark, ids.circle) end - if ids.text then pcall(trigger.action.removeMark, ids.text) end - self._MapMarkup[kind][zname] = nil -end - --- Public: set a specific zone active/inactive by kind and name -function CTLD:SetZoneActive(kind, name, active, silent) - if not (kind and name) then return end - local k = (kind == 'Pickup' or kind == 'Drop' or kind == 'FOB' or kind == 'MASH') and kind or nil - if not k then return end - self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } - self._ZoneActive[k][name] = (active ~= false) - -- Update drawings for this one zone only - if self.Config.MapDraw and self.Config.MapDraw.Enabled then - -- Find the MOOSE zone object by name - local list = (k=='Pickup' and self.PickupZones) or (k=='Drop' and self.DropZones) or (k=='FOB' and self.FOBZones) or (k=='MASH' and self.MASHZones) or {} - local mz - for _,z in ipairs(list or {}) do if z and z.GetName and z:GetName() == name then mz = z break end end - if self._ZoneActive[k][name] then - if mz then - local md = self.Config.MapDraw - local opts = { - OutlineColor = md.OutlineColor, - FillColor = (md.FillColors and md.FillColors[k]) or nil, - LineType = (md.LineTypes and md.LineTypes[k]) or md.LineType or 1, - FontSize = md.FontSize, - ReadOnly = (md.ReadOnly ~= false), - LabelOffsetX = md.LabelOffsetX, - LabelOffsetFromEdge = md.LabelOffsetFromEdge, - LabelOffsetRatio = md.LabelOffsetRatio, - LabelPrefix = ((md.LabelPrefixes and md.LabelPrefixes[k]) - or (k=='Pickup' and md.LabelPrefix) - or (k..' Zone')) - } - self:_drawZoneCircleAndLabel(k, mz, opts) - end - else - self:_removeZoneDrawing(k, name) - end - end - -- Optional messaging - local stateStr = self._ZoneActive[k][name] and 'ACTIVATED' or 'DEACTIVATED' - _logVerbose(string.format('Zone %s %s (%s)', tostring(name), stateStr, k)) - if not silent then - local msgKey = self._ZoneActive[k][name] and 'zone_activated' or 'zone_deactivated' - _eventSend(self, nil, self.Side, msgKey, { kind = k, zone = name }) - end -end - -function CTLD:DrawZonesOnMap() - local md = self.Config and self.Config.MapDraw or {} - if not md.Enabled then return end - -- Clear previous drawings before re-drawing - self:ClearMapDrawings() - local opts = { - OutlineColor = md.OutlineColor, - LineType = md.LineType, - FontSize = md.FontSize, - ReadOnly = (md.ReadOnly ~= false), - LabelPrefix = md.LabelPrefix or 'Zone', - LabelOffsetX = md.LabelOffsetX, - LabelOffsetFromEdge = md.LabelOffsetFromEdge, - LabelOffsetRatio = md.LabelOffsetRatio, - ForAll = (md.ForAll == true), - } - if md.DrawPickupZones then - for _,mz in ipairs(self.PickupZones or {}) do - local name = mz:GetName() - if self._ZoneActive.Pickup[name] ~= false then - opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.Pickup) or md.LabelPrefix or 'Pickup Zone' - opts.LineType = (md.LineTypes and md.LineTypes.Pickup) or md.LineType or 1 - opts.FillColor = (md.FillColors and md.FillColors.Pickup) or nil - self:_drawZoneCircleAndLabel('Pickup', mz, opts) - end - end - end - if md.DrawDropZones then - for _,mz in ipairs(self.DropZones or {}) do - local name = mz:GetName() - if self._ZoneActive.Drop[name] ~= false then - opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.Drop) or 'Drop Zone' - opts.LineType = (md.LineTypes and md.LineTypes.Drop) or md.LineType or 1 - opts.FillColor = (md.FillColors and md.FillColors.Drop) or nil - self:_drawZoneCircleAndLabel('Drop', mz, opts) - end - end - end - if md.DrawFOBZones then - for _,mz in ipairs(self.FOBZones or {}) do - local name = mz:GetName() - if self._ZoneActive.FOB[name] ~= false then - opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.FOB) or 'FOB Zone' - opts.LineType = (md.LineTypes and md.LineTypes.FOB) or md.LineType or 1 - opts.FillColor = (md.FillColors and md.FillColors.FOB) or nil - self:_drawZoneCircleAndLabel('FOB', mz, opts) - end - end - end - if md.DrawMASHZones then - for _,mz in ipairs(self.MASHZones or {}) do - local name = mz:GetName() - if self._ZoneActive.MASH[name] ~= false then - opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.MASH) or 'MASH' - opts.LineType = (md.LineTypes and md.LineTypes.MASH) or md.LineType or 1 - opts.FillColor = (md.FillColors and md.FillColors.MASH) or nil - self:_drawZoneCircleAndLabel('MASH', mz, opts) - end - end - if CTLD._mashZones then - for mashId, data in pairs(CTLD._mashZones) do - if data and data.side == self.Side and data.isMobile then - local zoneName = data.displayName or mashId - if self._ZoneActive.MASH[zoneName] ~= false then - opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.MASH) or 'MASH' - opts.LineType = (md.LineTypes and md.LineTypes.MASH) or md.LineType or 1 - opts.FillColor = (md.FillColors and md.FillColors.MASH) or nil - local zoneObj = data.zone - if not (zoneObj and zoneObj.GetPointVec3 and zoneObj.GetRadius) then - local pos = data.position or { x = 0, z = 0 } - if ZONE_RADIUS and VECTOR2 then - local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(pos.x, pos.z) or { x = pos.x, y = pos.z } - zoneObj = ZONE_RADIUS:New(zoneName, v2, data.radius or 500) - else - local posCopy = { x = pos.x, z = pos.z } - zoneObj = {} - function zoneObj:GetName() - return zoneName - end - function zoneObj:GetPointVec3() - return { x = posCopy.x, y = 0, z = posCopy.z } - end - function zoneObj:GetRadius() - return data.radius or 500 - end - end - data.zone = zoneObj - end - if zoneObj then - self:_drawZoneCircleAndLabel('MASH', zoneObj, opts) - end - end - end - end - end - end - if md.DrawSalvageZones then - for _,mz in ipairs(self.SalvageDropZones or {}) do - local name = mz:GetName() - if self._ZoneActive.SalvageDrop[name] ~= false then - opts.LabelPrefix = (md.LabelPrefixes and md.LabelPrefixes.SalvageDrop) or 'Salvage Zone' - opts.LineType = (md.LineTypes and md.LineTypes.SalvageDrop) or md.LineType or 1 - opts.FillColor = (md.FillColors and md.FillColors.SalvageDrop) or self.Config.SlingLoadSalvage.ZoneColors.fill - self:_drawZoneCircleAndLabel('SalvageDrop', mz, opts) - end - end - end -end - --- Unit preference detection and unit-aware formatting -local function _getPlayerIsMetric(unit) - local ok, isMetric = pcall(function() - local pname = unit and unit.GetPlayerName and unit:GetPlayerName() or nil - if pname and CTLD and CTLD._playerUnitPrefs then - local pref = CTLD._playerUnitPrefs[pname] - if pref == 'metric' then return true end - if pref == 'imperial' then return false end - end - if pname and type(SETTINGS) == 'table' and SETTINGS.Set then - local ps = SETTINGS:Set(pname) - if ps and ps.IsMetric then return ps:IsMetric() end - end - if _SETTINGS and _SETTINGS.IsMetric then return _SETTINGS:IsMetric() end - return true - end) - return (ok and isMetric) and true or false -end - -function CTLD:_SetGroupUnitPreference(group, mode) - if not group or not group:IsAlive() then return end - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then - _msgGroup(group, 'No active player unit to bind preference.') - return - end - local pname = unit.GetPlayerName and unit:GetPlayerName() or nil - if not pname or pname == '' then - _msgGroup(group, 'Unit preference requires a player-controlled slot.') - return - end - if mode ~= 'metric' and mode ~= 'imperial' and mode ~= nil then - _msgGroup(group, 'Invalid units selection requested.') - return - end - self._playerUnitPrefs = self._playerUnitPrefs or {} - if mode then - self._playerUnitPrefs[pname] = mode - else - self._playerUnitPrefs[pname] = nil - end - local label - if mode == 'metric' then - label = 'metric (meters)' - elseif mode == 'imperial' then - label = 'imperial (nautical miles / feet)' - else - label = 'mission default' - end - _msgGroup(group, string.format('CTLD units preference set to %s for %s.', label, pname)) -end - -function CTLD:_ShowGroupUnitPreference(group) - if not group or not group:IsAlive() then return end - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then - _msgGroup(group, 'No active player unit to inspect preference.') - return - end - local pname = unit.GetPlayerName and unit:GetPlayerName() or nil - if not pname or pname == '' then - _msgGroup(group, 'Unit preference requires a player-controlled slot.') - return - end - local prefs = self._playerUnitPrefs or {} - local explicit = prefs[pname] - local effective = _getPlayerIsMetric(unit) and 'metric (meters)' or 'imperial (nautical miles / feet)' - local source = explicit and 'CTLD preference' or 'mission default' - if explicit == 'metric' then - effective = 'metric (meters)' - elseif explicit == 'imperial' then - effective = 'imperial (nautical miles / feet)' - end - _msgGroup(group, string.format('%s units setting: %s (source: %s).', pname, effective, source)) -end - -local function _round(n, prec) - local m = 10^(prec or 0) - return math.floor(n * m + 0.5) / m -end - -local function _fmtDistance(meters, isMetric) - if isMetric then - local v = math.max(0, _round(meters, 0)) - return v, 'm' - else - local ft = meters * 3.28084 - -- snap to 5 ft increments for readability - ft = math.max(0, math.floor((ft + 2.5) / 5) * 5) - return ft, 'ft' - end -end - -local function _fmtRange(meters, isMetric) - meters = math.max(0, meters or 0) - if isMetric then - if meters >= 1000 then - local km = meters / 1000 - local prec = (km >= 10) and 0 or 1 - return _round(km, prec), 'km' - end - return _round(meters, 0), 'm' - else - local nm = meters / 1852 - local prec = (nm >= 10) and 0 or 1 - return _round(nm, prec), 'NM' - end -end - -local function _fmtSpeed(mps, isMetric) - if isMetric then - return _round(mps, 1), 'm/s' - else - local fps = mps * 3.28084 - return math.max(0, math.floor(fps + 0.5)), 'ft/s' - end -end - -local function _fmtAGL(meters, isMetric) - return _fmtDistance(meters, isMetric) -end - --- Coalition utility: return opposite side (BLUE<->RED); NEUTRAL returns RED by default -local function _enemySide(side) - if coalition and coalition.side then - if side == coalition.side.BLUE then return coalition.side.RED end - if side == coalition.side.RED then return coalition.side.BLUE end - end - return coalition.side.RED -end - --- Find nearest enemy-held base within radius; returns {point=vec3, name=string, dist=meters} -function CTLD:_findNearestEnemyBase(point, radius) - local enemy = _enemySide(self.Side) - local ok, bases = pcall(function() - if coalition and coalition.getAirbases then return coalition.getAirbases(enemy) end - return {} - end) - if not ok or not bases then return nil end - local best - for _,ab in ipairs(bases) do - local p = ab:getPoint() - local dx = (p.x - point.x); local dz = (p.z - point.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= radius and ((not best) or d < best.dist) then - best = { point = { x = p.x, z = p.z }, name = ab:getName() or 'Base', dist = d } - end - end - return best -end - --- Find nearest enemy ground group centroid within radius; returns {point=vec3, group=GROUP|nil, dcsGroupName=string, dist=meters, type=string} -function CTLD:_findNearestEnemyGround(point, radius) - local enemy = _enemySide(self.Side) - local best - -- Use MOOSE SET_GROUP to enumerate enemy ground groups - local set = SET_GROUP:New():FilterCoalitions(enemy):FilterCategories(Group.Category.GROUND):FilterActive(true):FilterStart() - set:ForEachGroup(function(g) - local alive = g:IsAlive() - if alive then - local c = g:GetCoordinate() - if c then - local v3 = c:GetVec3() - local dx = (v3.x - point.x); local dz = (v3.z - point.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= radius and ((not best) or d < best.dist) then - -- Try to infer a type label from first unit - local ut = 'unit' - local u1 = g:GetUnit(1) - if u1 then ut = _getUnitType(u1) or ut end - best = { point = { x = v3.x, z = v3.z }, group = g, dcsGroupName = g:GetName(), dist = d, type = ut } - end - end - end - end) - return best -end - --- Order a ground group by name to move toward target point at a given speed (km/h). Uses MOOSE route when available. -function CTLD:_orderGroundGroupToPointByName(groupName, targetPoint, speedKmh) - if not groupName or not targetPoint then return end - -- Pure-MOOSE movement: schedule a small delay so the dynamic group has - -- time to be fully registered in the MOOSE database before we attempt - -- to route it. DCS AI will handle targeting when enemies come into LOS; - -- we only care about advancing toward the chosen objective area. - - local delay = 2 -- seconds - local dest = { x = targetPoint.x, z = targetPoint.z } - timer.scheduleFunction(function() - local mg - local ok = pcall(function() mg = GROUP:FindByName(groupName) end) - if not (ok and mg and mg:IsAlive()) then - _logError(string.format("ATTACK AI: Failed to find group '%s' for routing", groupName or 'nil')) - return - end - - _logDebug(string.format("ATTACK AI: Routing group '%s' to target (%.1f, %.1f) at %d km/h", - groupName, dest.x, dest.z, speedKmh or 25)) - - local vec2 - if VECTOR2 and VECTOR2.New then - vec2 = VECTOR2:New(dest.x, dest.z) - else - vec2 = { x = dest.x, y = dest.z } - end - - local success = pcall(function() - -- Set ROE to allow engaging targets - if mg.OptionROEOpenFire then - mg:OptionROEOpenFire() - _logDebug(string.format("ATTACK AI: Set ROE OpenFire for '%s'", groupName)) - end - -- Set alarm state to Auto (alert and ready) - if mg.OptionAlarmStateAuto then - mg:OptionAlarmStateAuto() - _logDebug(string.format("ATTACK AI: Set AlarmState Auto for '%s'", groupName)) - end - - -- Create a temporary zone at the target point for TaskRouteToZone - -- This is the proven method used by DynamicGroundBattle plugin - local targetCoord = COORDINATE:New(dest.x, 0, dest.z) - local tempZone = ZONE_RADIUS:New("CTLD_TEMP_TARGET_" .. groupName, targetCoord:GetVec2(), 100) - - -- Use TaskRouteToZone with randomization (same as working DGB plugin) - mg:TaskRouteToZone(tempZone, true) - _logDebug(string.format("ATTACK AI: TaskRouteToZone issued for '%s' to (%.1f, %.1f)", groupName, dest.x, dest.z)) - end) - - if not success then - _logError(string.format("ATTACK AI: Failed to issue route commands for group '%s'", groupName)) - end - end, {}, timer.getTime() + delay) -end - --- Assign attack behavior to a newly spawned ground group by name -function CTLD:_assignAttackBehavior(groupName, originPoint, isVehicle) - if not (self.Config.AttackAI and self.Config.AttackAI.Enabled) then - _logDebug(string.format("ATTACK AI: Disabled or not configured for group '%s'", groupName or 'nil')) - return - end - - _logDebug(string.format("ATTACK AI: Assigning attack behavior to group '%s' (%s)", - groupName or 'nil', isVehicle and 'vehicle' or 'troops')) - - local radius = isVehicle and (self.Config.AttackAI.VehicleSearchRadius or 5000) or (self.Config.AttackAI.TroopSearchRadius or 3000) - local prioBase = (self.Config.AttackAI.PrioritizeEnemyBases ~= false) - local speed = isVehicle and (self.Config.AttackAI.VehicleAdvanceSpeedKmh or 35) or (self.Config.AttackAI.TroopAdvanceSpeedKmh or 20) - local player = 'Player' - - _logDebug(string.format("ATTACK AI: Search radius=%.0fm, prioritizeBase=%s, speed=%d km/h", - radius, tostring(prioBase), speed)) - - -- Try to infer last requesting player from crate/troop context is complex; caller should pass announcements separately when needed. - -- Target selection - -- SmartTargeting: always use omniscient nearest-target logic within radius, ignoring LOS. - -- We still optionally prioritize bases, but we no longer allow LOS/detection quirks to - -- prevent movement when enemies truly exist in the search area. - local target - local pickedBase - - local smart = (self.Config.AttackAI and self.Config.AttackAI.SmartTargeting ~= false) - - if prioBase then - local base = self:_findNearestEnemyBase(originPoint, radius) - if base then - target = { point = base.point, name = base.name, kind = 'base', dist = base.dist } - pickedBase = base - _logDebug(string.format("ATTACK AI: Found enemy base '%s' at %.0fm", base.name, base.dist)) - end - end - - if not target then - -- Primary omniscient search: nearest enemy ground group within radius. - local eg = self:_findNearestEnemyGround(originPoint, radius) - if eg then - target = { point = eg.point, name = eg.dcsGroupName, kind = 'enemy', dist = eg.dist, etype = eg.type } - _logDebug(string.format("ATTACK AI: Found enemy ground '%s' (%s) at %.0fm", eg.dcsGroupName, eg.type or 'unknown', eg.dist)) - end - end - - if not target then - _logDebug(string.format("ATTACK AI: No targets found within %.0fm for group '%s'", radius, groupName)) - end - - -- If SmartTargeting is disabled, simply honor the first hit (base or ground) and allow - -- the caller to fall back to defend when target is nil. - -- When SmartTargeting is enabled (default), we *only* fall back to defend when there are - -- truly no valid enemy bases or ground groups inside the configured radius. - -- (The actual omniscient search is already implemented in _findNearestEnemyBase/_findNearestEnemyGround.) - -- Order movement if we have a target - if target then - self:_orderGroundGroupToPointByName(groupName, target.point, speed) - end - return target -- caller will handle announcement -end - -local function _bearingDeg(from, to) - local dx = (to.x - from.x) - local dz = (to.z - from.z) - local ang = math.deg(math.atan2(dx, dz)) -- 0=N, +CW - if ang < 0 then ang = ang + 360 end - return math.floor(ang + 0.5) -end - --- Normalize MOOSE/DCS heading to both radians and degrees consistently. --- Some environments may yield degrees; others radians. This returns (rad, deg). -local function _headingRadDeg(unit) - local h = (unit and unit.GetHeading and unit:GetHeading()) or 0 - local hrad, hdeg - if h and h > (2*math.pi + 0.1) then - -- Looks like degrees - hdeg = h % 360 - hrad = math.rad(hdeg) - else - -- Radians (normalize into [0, 2pi)) - hrad = (h or 0) % (2*math.pi) - hdeg = math.deg(hrad) - end - return hrad, hdeg -end - -local function _projectToBodyFrame(dx, dz, hdg) - -- world (east=X=dx, north=Z=dz) to body frame (fwd/right) - local fwd = dx * math.sin(hdg) + dz * math.cos(hdg) - local right = dx * math.cos(hdg) - dz * math.sin(hdg) - return right, fwd -end - -local function _playerNameFromGroup(group) - if not group then return 'Player' end - local unit = group:GetUnit(1) - local pname = unit and unit.GetPlayerName and unit:GetPlayerName() - if pname and pname ~= '' then return pname end - return group:GetName() or 'Player' -end - -local function _coachSend(self, group, unitName, key, data, isCoach) - local cfg = CTLD.HoverCoachConfig or {} - if cfg.enabled and (not self.HoverSched) then - if self._startHoverScheduler then - self:_startHoverScheduler() - end - end - local now = timer.getTime() - CTLD._coachState[unitName] = CTLD._coachState[unitName] or { lastKeyTimes = {} } - local st = CTLD._coachState[unitName] - local last = st.lastKeyTimes[key] or 0 - local minGap = isCoach and ((cfg.throttle and cfg.throttle.coachUpdate) or 1.5) or ((cfg.throttle and cfg.throttle.generic) or 3.0) - local repeatGap = (cfg.throttle and cfg.throttle.repeatSame) or (minGap * 2) - if last > 0 and (now - last) < minGap then return end - -- prevent repeat spam of identical key too fast (only after first send) - if last > 0 and (now - last) < repeatGap then return end - local tpl = CTLD.Messages and CTLD.Messages[key] - if not tpl then return end - local text = _fmtTemplate(tpl, data) - if text and text ~= '' then - _msgGroup(group, text) - st.lastKeyTimes[key] = now - end -end - -local _groundLoadFallbacks = { - Start = "Ground crew on it—{count} crate(s) ready in {seconds}s.", - Progress = "Loading steady—{remaining}s to go.", - Complete = "Ground load complete—{count} crate(s) secure.", -} - -local function _prepareGroundLoadMessage(self, category, data) - data = data or {} - local comms = self.GroundLoadComms or {} - local pool = comms and comms[category] - local tpl - if pool and #pool > 0 then - tpl = pool[math.random(#pool)] - else - tpl = _groundLoadFallbacks[category] - end - data.ground_line = _fmtTemplate(tpl or '', data) - return data -end - -local function _eventSend(self, group, side, key, data) - local now = timer.getTime() - local scopeKey - if group then scopeKey = 'GRP:'..group:GetName() else scopeKey = 'COAL:'..tostring(side or self.Side) end - CTLD._msgState[scopeKey] = CTLD._msgState[scopeKey] or { lastKeyTimes = {} } - local st = CTLD._msgState[scopeKey] - local last = st.lastKeyTimes[key] or 0 - local cfg = CTLD.HoverCoachConfig - local minGap = (cfg and cfg.throttle and cfg.throttle.generic) or 3.0 - local repeatGap = (cfg and cfg.throttle and cfg.throttle.repeatSame) or (minGap * 2) - if last > 0 and (now - last) < minGap then return end - if last > 0 and (now - last) < repeatGap then return end - local tpl = CTLD.Messages and CTLD.Messages[key] - if not tpl then return end - local text = _fmtTemplate(tpl, data) - if not text or text == '' then return end - if group then _msgGroup(group, text) else _msgCoalition(side or self.Side, text) end - st.lastKeyTimes[key] = now -end - --- Format helpers for menu labels and recipe info -function CTLD:_recipeTotalCrates(def) - if not def then return 1 end - if type(def.requires) == 'table' then - local n = 0 - for _,qty in pairs(def.requires) do n = n + (qty or 0) end - return math.max(1, n) - end - return math.max(1, def.required or 1) -end - -function CTLD:_friendlyNameForKey(key) - local d = self.Config and self.Config.CrateCatalog and self.Config.CrateCatalog[key] - if not d then return tostring(key) end - return (d.menu or d.description or key) -end - -function CTLD:_formatMenuLabelWithCrates(key, def) - local base = (def and (def.menu or def.description)) or key - local total = self:_recipeTotalCrates(def) - local suffix = (total == 1) and '1 crate' or (tostring(total)..' crates') - -- Optionally append stock for UX; uses nearest pickup zone dynamically - if self.Config.Inventory and self.Config.Inventory.ShowStockInMenu then - local group = nil - -- Try to find any active group menu owner to infer nearest zone; if none, skip hint - for gname,_ in pairs(self.MenusByGroup or {}) do group = GROUP:FindByName(gname); if group then break end end - if group and group:IsAlive() then - local unit = group:GetUnit(1) - if unit and unit:IsAlive() then - local zone, dist = _nearestZonePoint(unit, self.Config.Zones and self.Config.Zones.PickupZones or {}) - if zone and dist and dist <= (self.Config.PickupZoneMaxDistance or 10000) then - local zname = zone:GetName() - -- For composite recipes, show bundle availability based on component stock; otherwise show per-key stock - if def and type(def.requires) == 'table' then - local stockTbl = CTLD._stockByZone[zname] or {} - local bundles = math.huge - for reqKey, qty in pairs(def.requires) do - local have = tonumber(stockTbl[reqKey] or 0) or 0 - local need = tonumber(qty or 0) or 0 - if need > 0 then bundles = math.min(bundles, math.floor(have / need)) end - end - if bundles == math.huge then bundles = 0 end - return string.format('%s (%s) [%s: %d bundle%s]', base, suffix, zname, bundles, (bundles==1 and '' or 's')) - else - local stock = (CTLD._stockByZone[zname] and CTLD._stockByZone[zname][key]) or 0 - return string.format('%s (%s) [%s: %d]', base, suffix, zname, stock) - end - end - end - end - end - return string.format('%s (%s)', base, suffix) -end - -function CTLD:_formatRecipeInfo(key, def) - local lines = {} - local title = self:_friendlyNameForKey(key) - table.insert(lines, string.format('%s', title)) - if def and def.isFOB then table.insert(lines, '(FOB recipe)') end - if def and type(def.requires) == 'table' then - local total = self:_recipeTotalCrates(def) - table.insert(lines, string.format('Requires: %d crate(s) total', total)) - table.insert(lines, 'Breakdown:') - -- stable order - local items = {} - for k,qty in pairs(def.requires) do table.insert(items, {k=k, q=qty}) end - table.sort(items, function(a,b) return tostring(a.k) < tostring(b.k) end) - for _,it in ipairs(items) do - local fname = self:_friendlyNameForKey(it.k) - table.insert(lines, string.format('- %dx %s', it.q or 1, fname)) - end - else - local n = self:_recipeTotalCrates(def) - table.insert(lines, string.format('Requires: %d crate(s)', n)) - end - if def and def.dcsCargoType then - table.insert(lines, string.format('Cargo type: %s', tostring(def.dcsCargoType))) - end - return table.concat(lines, '\n') -end - --- Determine an approximate radius for a ZONE. Tries MOOSE radius, then trigger zone radius, then configured radius. -function CTLD:_getZoneRadius(zone) - if zone and zone.Radius then return zone.Radius end - local name = zone and zone.GetName and zone:GetName() or nil - if name and trigger and trigger.misc and trigger.misc.getZone then - local z = trigger.misc.getZone(name) - if z and z.radius then return z.radius end - end - if name and self._ZoneDefs and self._ZoneDefs.FOBZones and self._ZoneDefs.FOBZones[name] then - local d = self._ZoneDefs.FOBZones[name] - if d and d.radius then return d.radius end - end - return 150 -end - --- Check if a 2D point (x,z) lies within any FOB zone; returns (bool, zone) -function CTLD:IsPointInFOBZones(point) - for _,z in ipairs(self.FOBZones or {}) do - local pz = z:GetPointVec3() - local r = self:_getZoneRadius(z) - local dx = (pz.x - point.x) - local dz = (pz.z - point.z) - if (dx*dx + dz*dz) <= (r*r) then return true, z end - end - return false, nil -end - --- #endregion Utilities - --- ========================= --- Construction --- ========================= --- #region Construction -function CTLD:New(cfg) - local o = setmetatable({}, self) - o.Config = DeepCopy(CTLD.Config) - if cfg then o.Config = DeepMerge(o.Config, cfg) end - - -- Debug: check if MASH zones survived the merge - _logDebug('After config merge:') - _logDebug(' o.Config.Zones exists: '..tostring(o.Config.Zones ~= nil)) - if o.Config.Zones then - _logDebug(' o.Config.Zones.MASHZones exists: '..tostring(o.Config.Zones.MASHZones ~= nil)) - if o.Config.Zones.MASHZones then - _logDebug(' o.Config.Zones.MASHZones count: '..tostring(#o.Config.Zones.MASHZones)) - end - end - - o.Side = o.Config.CoalitionSide - o.CountryId = o.Config.CountryId or _defaultCountryForSide(o.Side) - o.Config.CountryId = o.CountryId - o.MenuRoots = {} - o.MenusByGroup = {} - o._DynamicSalvageZones = {} - o._DynamicSalvageQueue = {} - o._jtacRegistry = {} - - -- Ground auto-load state tracking - CTLD._groundLoadState = CTLD._groundLoadState or {} - CTLD._groundLoadTimers = CTLD._groundLoadTimers or {} - - -- If caller disabled builtin catalog, clear it before merging any globals - if o.Config.UseBuiltinCatalog == false then - o.Config.CrateCatalog = {} - end - - -- If a global catalog was loaded earlier (via DO SCRIPT FILE), merge it automatically - -- Supported globals: _CTLD_EXTRACTED_CATALOG (our extractor), CTLD_CATALOG, MOOSE_CTLD_CATALOG - do - local globalsToCheck = { '_CTLD_EXTRACTED_CATALOG', 'CTLD_CATALOG', 'MOOSE_CTLD_CATALOG' } - for _,gn in ipairs(globalsToCheck) do - local t = rawget(_G, gn) - if type(t) == 'table' then - o:MergeCatalog(t) - _logInfo('Merged crate catalog from global '..gn) - end - end - end - - -- Load troop types from catalog if available - do - local troopTypes = rawget(_G, '_CTLD_TROOP_TYPES') - if type(troopTypes) == 'table' and next(troopTypes) then - o.Config.Troops.TroopTypes = troopTypes - _logInfo('Loaded troop types from _CTLD_TROOP_TYPES') - else - -- Fallback: catalog not loaded, warn user and provide minimal defaults - _logError('WARNING: _CTLD_TROOP_TYPES not found. Catalog may not be loaded. Using minimal troop fallbacks.') - _logError('Please ensure catalog file is loaded via DO SCRIPT FILE *before* creating CTLD instances.') - -- Minimal fallback troop types to prevent spawning wrong units - o.Config.Troops.TroopTypes = { - AS = { label = 'Assault Squad', size = 8, unitsBlue = { 'Soldier M4' }, unitsRed = { 'Infantry AK' }, units = { 'Infantry AK' } }, - AA = { label = 'MANPADS Team', size = 4, unitsBlue = { 'Soldier stinger' }, unitsRed = { 'SA-18 Igla-S manpad' }, units = { 'Infantry AK' } }, - AT = { label = 'AT Team', size = 4, unitsBlue = { 'Soldier M136' }, unitsRed = { 'Soldier RPG' }, units = { 'Infantry AK' } }, - AR = { label = 'Mortar Team', size = 4, unitsBlue = { '2B11 mortar' }, unitsRed = { '2B11 mortar' }, units = { '2B11 mortar' } }, - } - end - end - - -- Run unit type validation after catalogs/troop types load so issues surface early - o:_validateCatalogUnitTypes() - - o:InitZones() - -- Validate configured zones and warn if missing - o:ValidateZones() - -- Optional: draw configured zones on the F10 map - if o.Config.MapDraw and o.Config.MapDraw.Enabled then - -- Defer a tiny bit to ensure mission environment is fully up - timer.scheduleFunction(function() - pcall(function() o:DrawZonesOnMap() end) - end, {}, timer.getTime() + 1) - end - -- Optional: bind zone activation to mission flags (merge from config table and per-zone flag fields) - do - local merged = {} - -- Collect from explicit bindings (backward compatible) - if o.Config.ZoneEventBindings then - for _,b in ipairs(o.Config.ZoneEventBindings) do table.insert(merged, b) end - end - -- Collect from per-zone entries (preferred) - local function pushFromZones(kind, list) - for _,z in ipairs(list or {}) do - if z and z.name and z.flag then - table.insert(merged, { kind = kind, name = z.name, flag = z.flag, activeWhen = z.activeWhen or 1 }) - end - end - end - pushFromZones('Pickup', o.Config.Zones and o.Config.Zones.PickupZones) - pushFromZones('Drop', o.Config.Zones and o.Config.Zones.DropZones) - pushFromZones('FOB', o.Config.Zones and o.Config.Zones.FOBZones) - pushFromZones('MASH', o.Config.Zones and o.Config.Zones.MASHZones) - pushFromZones('SalvageDrop', o.Config.Zones and o.Config.Zones.SalvageDropZones) - - o._BindingsMerged = merged - if o._BindingsMerged and #o._BindingsMerged > 0 then - o._ZoneFlagState = {} - o._ZoneFlagsPrimed = false - o.ZoneFlagSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() - if not o._ZoneFlagsPrimed then - -- Prime states on first run without spamming messages - for _,b in ipairs(o._BindingsMerged) do - if b and b.flag and b.kind and b.name then - local val = (trigger and trigger.misc and trigger.misc.getUserFlag) and trigger.misc.getUserFlag(b.flag) or 0 - local activeWhen = (b.activeWhen ~= nil) and b.activeWhen or 1 - local shouldBeActive = (val == activeWhen) - local key = tostring(b.kind)..'|'..tostring(b.name) - o._ZoneFlagState[key] = shouldBeActive - o:SetZoneActive(b.kind, b.name, shouldBeActive, true) - end - end - o._ZoneFlagsPrimed = true - return - end - -- Subsequent runs: announce changes - for _,b in ipairs(o._BindingsMerged) do - if b and b.flag and b.kind and b.name then - local val = (trigger and trigger.misc and trigger.misc.getUserFlag) and trigger.misc.getUserFlag(b.flag) or 0 - local activeWhen = (b.activeWhen ~= nil) and b.activeWhen or 1 - local shouldBeActive = (val == activeWhen) - local key = tostring(b.kind)..'|'..tostring(b.name) - if o._ZoneFlagState[key] ~= shouldBeActive then - o._ZoneFlagState[key] = shouldBeActive - o:SetZoneActive(b.kind, b.name, shouldBeActive, false) - end - end - end - end) - if not ok then _logError('ZoneFlagSched error: '..tostring(err)) end - end, {}, 1, 1) - end - end - o:InitMenus() - - -- Initialize inventory for configured pickup zones (seed from catalog initialStock) - if o.Config.Inventory and o.Config.Inventory.Enabled then - pcall(function() o:InitInventory() end) - end - - -- Initialize MEDEVAC system - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - pcall(function() o:InitMEDEVAC() end) - end - - -- Initialize FARP system - if CTLD.FARPConfig and CTLD.FARPConfig.Enabled then - pcall(function() o:InitFARP() end) - end - - -- Initialize manual salvage crates (scan mission editor for pre-placed cargo) - if o.Config.SlingLoadSalvage and o.Config.SlingLoadSalvage.Enabled and o.Config.SlingLoadSalvage.EnableManualCrates then - pcall(function() o:ScanAndRegisterManualSalvageCrates() end) - end - - -- Periodic cleanup for crates - o.Sched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() o:CleanupCrates() end) - if not ok then _logError('CleanupCrates scheduler error: '..tostring(err)) end - end, {}, 60, 60) - - -- Periodic cleanup for deployed troops (remove dead/missing groups) - o.TroopCleanupSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() o:CleanupDeployedTroops() end) - if not ok then _logError('CleanupDeployedTroops scheduler error: '..tostring(err)) end - end, {}, 30, 30) - - -- Periodic comprehensive state maintenance (prune orphaned entries) - o.StateMaintSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() o:PruneOrphanedState() end) - if not ok then _logError('PruneOrphanedState scheduler error: '..tostring(err)) end - end, {}, 120, 120) -- Run every 2 minutes - - -- Optional: auto-build FOBs inside FOB zones when crates present - if o.Config.AutoBuildFOBInZones then - o.AutoFOBSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() o:AutoBuildFOBCheck() end) - if not ok then _logError('AutoBuildFOBCheck scheduler error: '..tostring(err)) end - end, {}, 10, 10) -- check every 10 seconds (tunable) - end - - -- Optional: hover pickup scanner - local coachCfg = CTLD.HoverCoachConfig or {} - if coachCfg.enabled then - o.HoverSched = nil - o:_startHoverScheduler() - end - - -- Optional: ground auto-load scanner - local groundCfg = CTLD.GroundAutoLoadConfig or {} - if groundCfg.Enabled then - o.GroundLoadSched = nil - o:_startGroundLoadScheduler() - end - - -- MEDEVAC auto-pickup and auto-unload scheduler - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - local checkInterval = (CTLD.MEDEVAC.AutoPickup and CTLD.MEDEVAC.AutoPickup.CheckInterval) or 3 - o.MEDEVACSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() o:ScanMEDEVACAutoActions() end) - if not ok then _logError('MEDEVAC auto-actions scheduler error: '..tostring(err)) end - end, {}, checkInterval, checkInterval) - end - - if o.Config.JTAC and o.Config.JTAC.Enabled then - local jtacInterval = 5 - if o.Config.JTAC.AutoLase then - local refresh = tonumber(o.Config.JTAC.AutoLase.RefreshSeconds) or 15 - local idle = tonumber(o.Config.JTAC.AutoLase.IdleRescanSeconds) or 30 - jtacInterval = math.max(2, math.min(refresh, idle, 10)) - end - o.JTACSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() o:_tickJTACs() end) - if not ok then _logError('JTAC tick scheduler error: '..tostring(err)) end - end, {}, jtacInterval, jtacInterval) - _logInfo(string.format('JTAC init: Enabled=TRUE AutoLase=%s SearchRadius=%s Refresh=%s IdleRescan=%s LockType=%s Verbose=%s Interval=%.1f', - tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.Enabled ~= false), - tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.SearchRadius), - tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.RefreshSeconds), - tostring(o.Config.JTAC.AutoLase and o.Config.JTAC.AutoLase.IdleRescanSeconds), - tostring(o.Config.JTAC.LockType), - tostring(o.Config.JTAC.Verbose), - jtacInterval)) - end - - table.insert(CTLD._instances, o) - o:_ensureBackgroundTasks() - local versionLabel = CTLD.Version or 'unknown' - _msgCoalition(o.Side, string.format('CTLD %s initialized for coalition', versionLabel)) - return o -end - -function CTLD:InitZones() - self.PickupZones = {} - self.DropZones = {} - self.FOBZones = {} - self.MASHZones = {} - self.SalvageDropZones = {} - self._ZoneDefs = { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {}, SalvageDropZones = {} } - self._ZoneActive = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } - for _,z in ipairs(self.Config.Zones.PickupZones or {}) do - local mz = _findZone(z) - if mz then - table.insert(self.PickupZones, mz) - local name = mz:GetName() - self._ZoneDefs.PickupZones[name] = z - if self._ZoneActive.Pickup[name] == nil then self._ZoneActive.Pickup[name] = (z.active ~= false) end - end - end - for _,z in ipairs(self.Config.Zones.DropZones or {}) do - local mz = _findZone(z) - if mz then - table.insert(self.DropZones, mz) - local name = mz:GetName() - self._ZoneDefs.DropZones[name] = z - if self._ZoneActive.Drop[name] == nil then self._ZoneActive.Drop[name] = (z.active ~= false) end - end - end - for _,z in ipairs(self.Config.Zones.FOBZones or {}) do - local mz = _findZone(z) - if mz then - table.insert(self.FOBZones, mz) - local name = mz:GetName() - self._ZoneDefs.FOBZones[name] = z - if self._ZoneActive.FOB[name] == nil then self._ZoneActive.FOB[name] = (z.active ~= false) end - end - end - for _,z in ipairs(self.Config.Zones.MASHZones or {}) do - local mz = _findZone(z) - if mz then - table.insert(self.MASHZones, mz) - local name = mz:GetName() - self._ZoneDefs.MASHZones[name] = z - if self._ZoneActive.MASH[name] == nil then self._ZoneActive.MASH[name] = (z.active ~= false) end - end - end - for _,z in ipairs(self.Config.Zones.SalvageDropZones or {}) do - local mz = _findZone(z) - if mz then - table.insert(self.SalvageDropZones, mz) - local name = mz:GetName() - if z and z.side == nil then z.side = self.Side end - self._ZoneDefs.SalvageDropZones[name] = z - if self._ZoneActive.SalvageDrop[name] == nil then self._ZoneActive.SalvageDrop[name] = (z.active ~= false) end - end - end -end - --- Validate configured zone names exist in the mission; warn coalition if any are missing. -function CTLD:ValidateZones() - local function zoneExistsByName(name) - if not name or name == '' then return false end - if trigger and trigger.misc and trigger.misc.getZone then - local z = trigger.misc.getZone(name) - if z then return true end - end - if ZONE and ZONE.FindByName then - local mz = ZONE:FindByName(name) - if mz then return true end - end - return false - end - - local function sideToStr(s) - if coalition and coalition.side then - if s == coalition.side.BLUE then return 'BLUE' end - if s == coalition.side.RED then return 'RED' end - if s == coalition.side.NEUTRAL then return 'NEUTRAL' end - end - return tostring(s) - end - - local function join(t) - local s = '' - for i,name in ipairs(t) do s = s .. (i>1 and ', ' or '') .. tostring(name) end - return s - end - - local missing = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } - local found = { Pickup = {}, Drop = {}, FOB = {}, MASH = {}, SalvageDrop = {} } - local coords = { Pickup = 0, Drop = 0, FOB = 0, MASH = 0, SalvageDrop = 0 } - - for _,z in ipairs(self.Config.Zones.PickupZones or {}) do - if z.name then - if zoneExistsByName(z.name) then table.insert(found.Pickup, z.name) else table.insert(missing.Pickup, z.name) end - elseif z.coord then - coords.Pickup = coords.Pickup + 1 - end - end - for _,z in ipairs(self.Config.Zones.DropZones or {}) do - if z.name then - if zoneExistsByName(z.name) then table.insert(found.Drop, z.name) else table.insert(missing.Drop, z.name) end - elseif z.coord then - coords.Drop = coords.Drop + 1 - end - end - for _,z in ipairs(self.Config.Zones.FOBZones or {}) do - if z.name then - if zoneExistsByName(z.name) then table.insert(found.FOB, z.name) else table.insert(missing.FOB, z.name) end - elseif z.coord then - coords.FOB = coords.FOB + 1 - end - end - for _,z in ipairs(self.Config.Zones.MASHZones or {}) do - if z.name then - if zoneExistsByName(z.name) then table.insert(found.MASH, z.name) else table.insert(missing.MASH, z.name) end - elseif z.coord then - coords.MASH = coords.MASH + 1 - end - end - for _,z in ipairs(self.Config.Zones.SalvageDropZones or {}) do - if z.name then - if zoneExistsByName(z.name) then table.insert(found.SalvageDrop, z.name) else table.insert(missing.SalvageDrop, z.name) end - elseif z.coord then - coords.SalvageDrop = coords.SalvageDrop + 1 - end - end - - -- Log a concise summary to dcs.log - local sideStr = sideToStr(self.Side) - _logVerbose(string.format('[ZoneValidation][%s] Pickup: configured=%d (named=%d, coord=%d) found=%d missing=%d', - sideStr, - #(self.Config.Zones.PickupZones or {}), #found.Pickup + #missing.Pickup, coords.Pickup, #found.Pickup, #missing.Pickup)) - _logVerbose(string.format('[ZoneValidation][%s] Drop : configured=%d (named=%d, coord=%d) found=%d missing=%d', - sideStr, - #(self.Config.Zones.DropZones or {}), #found.Drop + #missing.Drop, coords.Drop, #found.Drop, #missing.Drop)) - _logVerbose(string.format('[ZoneValidation][%s] FOB : configured=%d (named=%d, coord=%d) found=%d missing=%d', - sideStr, - #(self.Config.Zones.FOBZones or {}), #found.FOB + #missing.FOB, coords.FOB, #found.FOB, #missing.FOB)) - _logVerbose(string.format('[ZoneValidation][%s] MASH : configured=%d (named=%d, coord=%d) found=%d missing=%d', - sideStr, - #(self.Config.Zones.MASHZones or {}), #found.MASH + #missing.MASH, coords.MASH, #found.MASH, #missing.MASH)) - _logVerbose(string.format('[ZoneValidation][%s] Salvage: configured=%d (named=%d, coord=%d) found=%d missing=%d', - sideStr, - #(self.Config.Zones.SalvageDropZones or {}), #found.SalvageDrop + #missing.SalvageDrop, coords.SalvageDrop, #found.SalvageDrop, #missing.SalvageDrop)) - - local anyMissing = (#missing.Pickup > 0) or (#missing.Drop > 0) or (#missing.FOB > 0) or (#missing.MASH > 0) or (#missing.SalvageDrop > 0) - if anyMissing then - if #missing.Pickup > 0 then - local msg = 'CTLD config warning: Missing Pickup Zones: '..join(missing.Pickup) - _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) - end - if #missing.Drop > 0 then - local msg = 'CTLD config warning: Missing Drop Zones: '..join(missing.Drop) - _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) - end - if #missing.FOB > 0 then - local msg = 'CTLD config warning: Missing FOB Zones: '..join(missing.FOB) - _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) - end - if #missing.MASH > 0 then - local msg = 'CTLD config warning: Missing MASH Zones: '..join(missing.MASH) - _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) - end - if #missing.SalvageDrop > 0 then - local msg = 'CTLD config warning: Missing Salvage Drop Zones: '..join(missing.SalvageDrop) - _msgCoalition(self.Side, msg); _logError('[ZoneValidation]['..sideStr..'] '..msg) - end - else - _logVerbose(string.format('[ZoneValidation][%s] All configured zone names resolved successfully.', sideStr)) - end - - self._MissingZones = missing -end --- #endregion Construction - --- ========================= --- Menus --- ========================= --- #region Menus -function CTLD:InitMenus() - if self.Config.UseGroupMenus then - -- Create placeholder menu at mission start to reserve F10 position if requested - if self.Config.CreateMenuAtMissionStart then - -- Create a coalition-level placeholder that will be replaced by per-group menus on birth - self.PlaceholderMenu = MENU_COALITION:New(self.Side, self.Config.RootMenuName or 'CTLD') - MENU_COALITION_COMMAND:New(self.Side, 'Spawn in an aircraft to see options', self.PlaceholderMenu, function() - _msgCoalition(self.Side, 'CTLD menus will appear when you spawn in a transport aircraft.') - end) - end - self:WireBirthHandler() - -- No coalition-level root when using per-group menus; Admin/Help is nested under each group's CTLD menu - else - self.MenuRoot = MENU_COALITION:New(self.Side, self.Config.RootMenuName or 'CTLD') - self:BuildCoalitionMenus(self.MenuRoot) - self:InitCoalitionAdminMenu() - end -end - -function CTLD:WireBirthHandler() - local handler = EVENTHANDLER:New() - handler:HandleEvent(EVENTS.Birth) - handler:HandleEvent(EVENTS.Dead) - handler:HandleEvent(EVENTS.Crash) - handler:HandleEvent(EVENTS.PilotDead) - handler:HandleEvent(EVENTS.Ejection) - handler:HandleEvent(EVENTS.PlayerLeaveUnit) - local selfref = self - - local function hasTrackedState(gname) - if not gname then return false end - if selfref.MenusByGroup and selfref.MenusByGroup[gname] then return true end - local tbls = { - CTLD._inStockMenus, - CTLD._loadedCrates, - CTLD._troopsLoaded, - CTLD._loadedTroopTypes, - CTLD._deployedTroops, - CTLD._buildConfirm, - CTLD._buildCooldown, - CTLD._coachOverride, - CTLD._medevacUnloadStates, - CTLD._medevacLoadStates, - CTLD._medevacEnrouteStates, - } - for _, tbl in ipairs(tbls) do - if tbl and tbl[gname] then return true end - end - if CTLD._msgState and CTLD._msgState['GRP:'..gname] then return true end - return false - end - - local function teardownIfGroupInactive(eventData, reason) - if not eventData then return end - local unit = eventData.IniUnit - local group = eventData.IniGroup or (unit and unit.GetGroup and unit:GetGroup()) or nil - if not group or not group.GetName then return end - if group.GetCoalition and group:GetCoalition() ~= selfref.Side then return end - local gname = group:GetName() - if not gname or gname == '' then return end - if not hasTrackedState(gname) then return end - local allowed = selfref.Config and selfref.Config.AllowedAircraft or {} - if _groupHasAliveTransport(group, allowed) then return end - selfref:_cleanupTransportGroup(group, gname) - if reason then - _logDebug(string.format('[MenuCleanup] Group %s removed due to %s', gname, reason)) - end - end - - local function scheduleDeferredCleanup(group) - if not (group and group.GetName) then return end - local gname = group:GetName() - if not gname or gname == '' then return end - if not hasTrackedState(gname) then return end - if not (timer and timer.scheduleFunction) then return end - timer.scheduleFunction(function(arg) - local name = arg and arg.groupName or nil - if not name then return nil end - if not selfref then return nil end - local grp = GROUP and GROUP:FindByName(name) or nil - local allowed = selfref.Config and selfref.Config.AllowedAircraft or {} - if grp and _groupHasAliveTransport(grp, allowed) then - return nil - end - selfref:_cleanupTransportGroup(grp, name) - _logDebug(string.format('[MenuCleanup] Group %s cleaned up after player left', name)) - return nil - end, { groupName = gname }, timer.getTime() + 3) - end - - function handler:OnEventBirth(eventData) - local unit = eventData.IniUnit - if not unit or not unit:IsAlive() then return end - if unit:GetCoalition() ~= selfref.Side then return end - local typ = _getUnitType(unit) - if not _isIn(selfref.Config.AllowedAircraft, typ) then return end - local grp = unit:GetGroup() - if not grp then return end - local gname = grp:GetName() - if selfref.MenusByGroup[gname] then return end - selfref.MenusByGroup[gname] = selfref:BuildGroupMenus(grp) - _msgGroup(grp, 'CTLD menu available (F10)') - end - - function handler:OnEventDead(eventData) - teardownIfGroupInactive(eventData, 'Dead') - end - - function handler:OnEventCrash(eventData) - teardownIfGroupInactive(eventData, 'Crash') - end - - function handler:OnEventPilotDead(eventData) - teardownIfGroupInactive(eventData, 'PilotDead') - end - - function handler:OnEventEjection(eventData) - teardownIfGroupInactive(eventData, 'Ejection') - end - - function handler:OnEventPlayerLeaveUnit(eventData) - local unit = eventData and eventData.IniUnit - if not unit then return end - if unit.GetCoalition and unit:GetCoalition() ~= selfref.Side then return end - local group = unit.GetGroup and unit:GetGroup() or nil - if not group then return end - scheduleDeferredCleanup(group) - end - - self.BirthHandler = handler -end - -function CTLD:BuildGroupMenus(group) - local root = MENU_GROUP:New(group, self.Config.RootMenuName or 'CTLD') - -- Safe menu command helper: wraps callbacks to prevent silent errors - local function CMD(title, parent, cb) - return MENU_GROUP_COMMAND:New(group, title, parent, function() - local ok, err = pcall(cb) - if not ok then - _logError('Menu error: '..tostring(err)) - MESSAGE:New('CTLD menu error: '..tostring(err), 8):ToGroup(group) - end - end) - end - - -- Initialize per-player coach preference from default - local gname = group:GetName() - CTLD._coachOverride = CTLD._coachOverride or {} - if CTLD._coachOverride[gname] == nil then - local coachCfg = CTLD.HoverCoachConfig or {} - CTLD._coachOverride[gname] = coachCfg.coachOnByDefault - end - - -- Top-level roots per requested structure - local opsRoot = MENU_GROUP:New(group, 'Operations', root) - local logRoot = MENU_GROUP:New(group, 'Logistics', root) - local toolsRoot = MENU_GROUP:New(group, 'Field Tools', root) - local navRoot = MENU_GROUP:New(group, 'Navigation', root) - local adminRoot = MENU_GROUP:New(group, 'Admin/Help', root) - - local prefsMenu = MENU_GROUP:New(group, 'Preferences', adminRoot) - CMD('Use Metric Units', prefsMenu, function() self:_SetGroupUnitPreference(group, 'metric') end) - CMD('Use Imperial Units', prefsMenu, function() self:_SetGroupUnitPreference(group, 'imperial') end) - CMD('Use Mission Default Units', prefsMenu, function() self:_SetGroupUnitPreference(group, nil) end) - CMD('Show My Units Setting', prefsMenu, function() self:_ShowGroupUnitPreference(group) end) - - -- Admin/Help -> Player Guides (moved to top of Admin/Help) - local help = MENU_GROUP:New(group, 'Player Guides', adminRoot) - MENU_GROUP_COMMAND:New(group, 'CTLD Basics (2-minute tour)', help, function() - local lines = {} - table.insert(lines, 'CTLD Basics - 2 minute tour') - table.insert(lines, '') - table.insert(lines, 'Loop: Request -> Deliver -> Build -> Fight') - table.insert(lines, '- Request crates at an ACTIVE Supply Zone (Pickup).') - table.insert(lines, '- Deliver crates to the build point (within Build Radius).') - table.insert(lines, '- Build units or sites with "Build Here" (confirm + cooldown).') - table.insert(lines, '- Optional: set Attack or Defend behavior when building.') - table.insert(lines, '') - table.insert(lines, 'Key concepts:') - table.insert(lines, '- Zones: Pickup (supply), Drop (mission targets), FOB (forward supply).') - table.insert(lines, '- Inventory: stock is tracked per zone; requests consume stock there.') - table.insert(lines, '- FOBs: building one creates a local supply point with seeded stock.') - table.insert(lines, '- Advanced: SAM site repair crates, AI attack orders, EWR/JTAC support.') - MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'Zones - Guide', help, function() - local lines = {} - table.insert(lines, 'CTLD Zones - Guide') - table.insert(lines, '') - table.insert(lines, 'Zone types:') - table.insert(lines, '- Pickup (Supply): Request crates and load troops here. Crate requests require proximity to an ACTIVE pickup zone (default within 10 km).') - table.insert(lines, '- Drop: Mission-defined delivery or rally areas. Some missions may require delivery or deployment at these zones (see briefing).') - table.insert(lines, '- FOB: Forward Operating Base areas. Some recipes (FOB Site) can be built here; if FOB restriction is enabled, FOB-only builds must be inside an FOB zone.') - table.insert(lines, '') - table.insert(lines, 'Colors and map marks:') - table.insert(lines, '- Pickup zone crate spawns are marked with smoke in the configured color. Admin/Help -> Draw CTLD Zones on Map draws zone circles and labels on F10.') - table.insert(lines, '- Use Admin/Help -> Clear CTLD Map Drawings to remove the drawings. Drawings are read-only if configured.') - table.insert(lines, '') - table.insert(lines, 'How to use zones:') - table.insert(lines, '- To request crates: move within the pickup zone distance and use CTLD -> Request Crate.') - table.insert(lines, '- To load troops: must be inside a Pickup zone if troop loading restriction is enabled.') - table.insert(lines, '- Navigation: CTLD -> Coach & Nav -> Vectors to Nearest Pickup Zone gives bearing and range.') - table.insert(lines, '- Activation: Zones can be active/inactive per mission logic; inactive pickup zones block crate requests.') - table.insert(lines, '') - table.insert(lines, string.format('Build Radius: about %d m to collect nearby crates when building.', self.Config.BuildRadius or 100)) - table.insert(lines, string.format('Pickup Zone Max Distance: about %d m to request crates.', self.Config.PickupZoneMaxDistance or 10000)) - MESSAGE:New(table.concat(lines, '\n'), 40):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'Inventory - How It Works', help, function() - local inv = self.Config.Inventory or {} - local enabled = inv.Enabled ~= false - local showHint = inv.ShowStockInMenu == true - local fobPct = math.floor(((inv.FOBStockFactor or 0.25) * 100) + 0.5) - local lines = {} - table.insert(lines, 'CTLD Inventory - How It Works') - table.insert(lines, '') - table.insert(lines, 'Overview:') - table.insert(lines, '- Inventory is tracked per Supply (Pickup) Zone and per FOB. Requests consume stock at that location.') - table.insert(lines, string.format('- Inventory is %s.', enabled and 'ENABLED' or 'DISABLED')) - table.insert(lines, '') - table.insert(lines, 'Starting stock:') - table.insert(lines, '- Each configured Supply Zone is seeded from the catalog initialStock for every crate type at mission start.') - table.insert(lines, string.format('- When you build a FOB, it creates a small Supply Zone with stock seeded at ~%d%% of initialStock.', fobPct)) - table.insert(lines, '') - table.insert(lines, 'Requesting crates:') - table.insert(lines, '- You must be within range of an ACTIVE Supply Zone to request crates; stock is decremented on spawn.') - table.insert(lines, '- If out of stock for a type at that zone, requests are denied for that type until resupplied (mission logic).') - table.insert(lines, '') - table.insert(lines, 'UI hints:') - table.insert(lines, string.format('- Show stock in menu labels: %s.', showHint and 'ON' or 'OFF')) - table.insert(lines, '- Some missions may include an "In Stock Here" list showing only items available at the nearest zone.') - MESSAGE:New(table.concat(lines, '\n'), 40):ToGroup(group) - end) - - MENU_GROUP_COMMAND:New(group, 'Troop Transport & JTAC Use', help, function() - local lines = {} - table.insert(lines, 'Troop Transport & JTAC Use') - table.insert(lines, '') - table.insert(lines, 'Troops:') - table.insert(lines, '- Load inside an ACTIVE Supply Zone (if mission enforces it).') - table.insert(lines, '- Deploy with Defend (hold) or Attack (advance to targets/bases).') - table.insert(lines, '- Attack uses a search radius and moves at configured speed.') - table.insert(lines, '') - table.insert(lines, 'JTAC:') - table.insert(lines, '- Build JTAC units (MRAP/Tigr or drones) to support target marking.') - table.insert(lines, '- JTAC helps with target designation/SA; details depend on mission setup.') - MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'Crates 101: Requesting and Handling', help, function() - local lines = {} - table.insert(lines, 'Crates 101 - Requesting and Handling') - table.insert(lines, '') - table.insert(lines, '- Request crates near an ACTIVE Supply Zone; max distance is configurable.') - table.insert(lines, '- Menu labels show the total crates required for a recipe.') - table.insert(lines, '- Drop crates close together but avoid overlap; smoke marks spawns.') - table.insert(lines, '- Use Coach & Nav tools: vectors to nearest pickup zone, re-mark crate with smoke.') - MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'Hover Pickup & Slingloading', help, function() - local coachCfg = CTLD.HoverCoachConfig or {} - local aglMin = (coachCfg.thresholds and coachCfg.thresholds.aglMin) or 5 - local aglMax = (coachCfg.thresholds and coachCfg.thresholds.aglMax) or 20 - local capGS = (coachCfg.thresholds and coachCfg.thresholds.captureGS) or (4/3.6) - local hold = (coachCfg.thresholds and coachCfg.thresholds.stabilityHold) or 1.8 - local lines = {} - table.insert(lines, 'Hover Pickup & Slingloading') - table.insert(lines, '') - table.insert(lines, string.format('- Hover pickup: hold AGL %d-%d m, speed < %.1f m/s, for ~%.1f s to auto-load.', aglMin, aglMax, capGS, hold)) - table.insert(lines, '- Keep steady within ~15 m of the crate; Hover Coach gives cues if enabled.') - table.insert(lines, '- Slingloading tips: avoid rotor wash over stacks; approach from upwind; re-mark crate with smoke if needed.') - MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'Build System: Build Here and Advanced', help, function() - local br = self.Config.BuildRadius or 100 - local win = self.Config.BuildConfirmWindowSeconds or 10 - local cd = self.Config.BuildCooldownSeconds or 60 - local lines = {} - table.insert(lines, 'Build System - Build Here and Advanced') - table.insert(lines, '') - table.insert(lines, string.format('- Build Here collects crates within ~%d m. Double-press within %d s to confirm.', br, win)) - table.insert(lines, string.format('- Cooldown: about %d s per group after a successful build.', cd)) - table.insert(lines, '- Advanced Build lets you choose Defend (hold) or Attack (move).') - table.insert(lines, '- Static or unsuitable units will hold even if Attack is chosen.') - table.insert(lines, '- FOB-only recipes must be inside an FOB zone when restriction is enabled.') - MESSAGE:New(table.concat(lines, '\n'), 40):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'FOBs: Forward Supply & Why They Matter', help, function() - local fobPct = math.floor(((self.Config.Inventory and self.Config.Inventory.FOBStockFactor or 0.25) * 100) + 0.5) - local lines = {} - table.insert(lines, 'FOBs - Forward Supply and Why They Matter') - table.insert(lines, '') - table.insert(lines, '- Build a FOB by assembling its crate recipe (see Recipe Info).') - table.insert(lines, string.format('- A new local Supply Zone is created and seeded at ~%d%% of initial stock.', fobPct)) - table.insert(lines, '- FOBs shorten logistics legs and increase throughput toward the front.') - table.insert(lines, '- If enabled, FOB-only builds must occur inside FOB zones.') - MESSAGE:New(table.concat(lines, '\n'), 35):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'SAM Sites: Building, Repairing, and Augmenting', help, function() - local br = self.Config.BuildRadius or 100 - local lines = {} - table.insert(lines, 'SAM Sites - Building, Repairing, and Augmenting') - table.insert(lines, '') - table.insert(lines, 'Build:') - table.insert(lines, '- Assemble site recipes using the required component crates (see menu labels). Build Here will place the full site.') - table.insert(lines, '') - table.insert(lines, 'Repair/Augment (merged):') - table.insert(lines, '- Request the matching "Repair/Launcher +1" crate for your site type (HAWK, Patriot, KUB, BUK).') - table.insert(lines, string.format('- Drop repair crate(s) within ~%d m of the site, then use Build Here (confirm window applies).', br)) - table.insert(lines, '- The nearest matching site (within a local search) is respawned fully repaired; +1 launcher per crate, up to caps.') - table.insert(lines, '- Caps: HAWK 6, Patriot 6, KUB 3, BUK 6. Extra crates beyond the cap are not consumed.') - table.insert(lines, '- Must match coalition and site type; otherwise no changes are applied.') - table.insert(lines, '- Respawn is required to apply repairs/augmentation due to DCS limitations.') - table.insert(lines, '') - table.insert(lines, 'Placement tips:') - table.insert(lines, '- Space launchers to avoid masking; keep radars with good line-of-sight; avoid fratricide arcs.') - MESSAGE:New(table.concat(lines, '\n'), 45):ToGroup(group) - end) - MENU_GROUP_COMMAND:New(group, 'MASH & Salvage System', help, function() - local lines = {} - table.insert(lines, 'MASH & Salvage System - Player Guide') - table.insert(lines, '') - table.insert(lines, 'What is it?') - table.insert(lines, '- MASH (Mobile Army Surgical Hospital) zones accept MEDEVAC crew deliveries.') - table.insert(lines, '- When ground vehicles are destroyed, crews spawn nearby and call for rescue.') - table.insert(lines, '- Rescuing crews and delivering them to MASH earns Salvage Points for your coalition.') - table.insert(lines, '- Salvage Points let you build out-of-stock items, keeping logistics flowing.') - table.insert(lines, '') - table.insert(lines, 'How MEDEVAC works:') - table.insert(lines, '- Vehicle destroyed → crew spawns after delay with invulnerability period.') - table.insert(lines, '- MEDEVAC request announced with grid coordinates and salvage value.') - table.insert(lines, '- Crews have a time limit (default 60 minutes); failure = crew KIA and vehicle lost.') - table.insert(lines, '') - table.insert(lines, 'Pickup Methods:') - table.insert(lines, '- AUTO: Land within 500m of crew - they will run to you and board automatically!') - table.insert(lines, '- HOVER: Fly close, hover nearby, load troops normally - system detects MEDEVAC crew.') - table.insert(lines, '- Original vehicle respawns when crew is picked up (if enabled).') - table.insert(lines, '') - table.insert(lines, 'Delivering to MASH:') - table.insert(lines, '- AUTO: Land in any MASH zone - crews unload automatically after 2 seconds.') - table.insert(lines, '- MANUAL: Deploy troops inside MASH zone - salvage points awarded automatically.') - table.insert(lines, '- Coalition message shows points earned and new total.') - table.insert(lines, '') - table.insert(lines, 'Using Salvage Points:') - table.insert(lines, '- When crate requests fail (out of stock), salvage auto-applies if available.') - table.insert(lines, '- Each catalog item has a salvage cost (usually matches its value).') - table.insert(lines, '- Check current salvage: Coach & Nav -> MEDEVAC Status.') - table.insert(lines, '') - table.insert(lines, 'Mobile MASH:') - table.insert(lines, '- Build Mobile MASH crates to deploy field hospitals anywhere.') - table.insert(lines, '- Mobile MASH creates a new delivery zone with radio beacon.') - table.insert(lines, '- Multiple mobile MASHs can be deployed for forward operations.') - table.insert(lines, '') - table.insert(lines, 'Best practices:') - table.insert(lines, '- Monitor MEDEVAC requests: Coach & Nav -> Vectors to Nearest MEDEVAC Crew.') - table.insert(lines, '- Prioritize high-value vehicles (armor, AA) for maximum salvage.') - table.insert(lines, '- Deploy Mobile MASH near active combat zones to reduce delivery time.') - table.insert(lines, '- Coordinate with team: share MEDEVAC locations and salvage status.') - table.insert(lines, '- Watch for warnings: 15min and 5min alerts before crew timeout.') - MESSAGE:New(table.concat(lines, '\n'), 50):ToGroup(group) - end) - - -- Operations -> Troop Transport - local troopsRoot = MENU_GROUP:New(group, 'Troop Transport', opsRoot) - CMD('Load Troops', troopsRoot, function() self:LoadTroops(group) end) - -- Optional typed troop loading submenu - do - local typedRoot = MENU_GROUP:New(group, 'Load Troops (Type)', troopsRoot) - local tcfg = (self.Config.Troops and self.Config.Troops.TroopTypes) or {} - -- Stable order per common roles - local order = { 'AS', 'AA', 'AT', 'AR' } - local seen = {} - local function addItem(key) - local def = tcfg[key] - if not def then return end - local label = (def.label or key) - local size = def.size or 6 - CMD(string.format('%s (%d)', label, size), typedRoot, function() - self:LoadTroops(group, { typeKey = key }) - end) - seen[key] = true - end - for _,k in ipairs(order) do addItem(k) end - -- Add any additional custom types not in the default order - for k,_ in pairs(tcfg) do if not seen[k] then addItem(k) end end - end - do - local tr = (self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000 - CMD('Deploy [Hold Position]', troopsRoot, function() self:UnloadTroops(group, { behavior = 'defend' }) end) - CMD(string.format('Deploy [Attack (%dm)]', tr), troopsRoot, function() self:UnloadTroops(group, { behavior = 'attack' }) end) - end - - -- Operations -> JTAC - do - local jtacRoot = MENU_GROUP:New(group, 'JTAC', opsRoot) - -- Track per-group active JTAC selection - CTLD._activeJTACByGroup = CTLD._activeJTACByGroup or {} - - -- Select Active JTAC: cycle to nearest or next if already selected - CMD('Select Active JTAC (cycle nearest)', jtacRoot, function() self:JTAC_SelectActiveForGroup(group, { mode = 'nearest' }) end) - - -- Control submenu - local ctl = MENU_GROUP:New(group, 'Control', jtacRoot) - CMD('Pause/Resume Auto-Lase', ctl, function() self:JTAC_TogglePause(group) end) - CMD('Release Current Target', ctl, function() self:JTAC_ReleaseTarget(group) end) - CMD('Force Rescan / Reacquire', ctl, function() self:JTAC_ForceRescan(group) end) - - -- Targeting submenu - local tgt = MENU_GROUP:New(group, 'Targeting', jtacRoot) - local lock = MENU_GROUP:New(group, 'Lock Filter', tgt) - CMD('All', lock, function() self:JTAC_SetLockFilter(group, 'all') end) - CMD('Vehicles only', lock, function() self:JTAC_SetLockFilter(group, 'vehicle') end) - CMD('Troops only', lock, function() self:JTAC_SetLockFilter(group, 'troop') end) - local prof = MENU_GROUP:New(group, 'Priority Profile', tgt) - CMD('Threat (SAM>AAA>Armor>IFV>Arty>Inf)', prof, function() self:JTAC_SetPriority(group, 'threat') end) - CMD('Armor-first', prof, function() self:JTAC_SetPriority(group, 'armor') end) - CMD('Soft-first', prof, function() self:JTAC_SetPriority(group, 'soft') end) - CMD('Infantry-last', prof, function() self:JTAC_SetPriority(group, 'inf_last') end) - - -- Range & Effects submenu - local rng = MENU_GROUP:New(group, 'Range & Effects', jtacRoot) - local sr = MENU_GROUP:New(group, 'Search Radius', rng) - for _,km in ipairs({4,6,8,10,12}) do - CMD(string.format('%d km', km), sr, function() self:JTAC_SetSearchRadius(group, km*1000) end) - end - local sm = MENU_GROUP:New(group, 'Smoke', rng) - CMD('Toggle Smoke On/Off', sm, function() self:JTAC_ToggleSmoke(group) end) - CMD('Color: Blue', sm, function() self:JTAC_SetSmokeColor(group, 'blue') end) - CMD('Color: Orange', sm, function() self:JTAC_SetSmokeColor(group, 'orange') end) - - -- Comms & Laser submenu - local comm = MENU_GROUP:New(group, 'Comms & Laser', jtacRoot) - CMD('Announcements On/Off', comm, function() self:JTAC_ToggleAnnouncements(group) end) - -- Laser code management can be added in phase 2 - - -- Utilities - local util = MENU_GROUP:New(group, 'Utilities', jtacRoot) - CMD('Mark Current Target on Map', util, function() self:JTAC_MarkCurrentTarget(group) end) - -- Rename/Dismiss can be added in phase 2 - - -- Status & Diagnostics (keep at bottom of JTAC) - CMD('List JTAC Status', jtacRoot, function() self:ListJTACStatus(group) end) - CMD('JTAC Diagnostics', jtacRoot, function() self:JTACDiagnostics(group) end) - end - - -- Operations -> MEDEVAC - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - local medevacRoot = MENU_GROUP:New(group, 'MEDEVAC', opsRoot) - CMD('Show Onboard Manifest', medevacRoot, function() self:ShowOnboardManifest(group) end) - - -- List Active MEDEVAC Requests - CMD('List Active MEDEVAC Requests', medevacRoot, function() self:ListActiveMEDEVACRequests(group) end) - - -- Nearest MEDEVAC Location - CMD('Nearest MEDEVAC Location', medevacRoot, function() self:NearestMEDEVACLocation(group) end) - - -- Coalition Salvage Points - CMD('Coalition Salvage Points', medevacRoot, function() self:ShowSalvagePoints(group) end) - - -- Vectors to Nearest MEDEVAC - CMD('Vectors to Nearest MEDEVAC', medevacRoot, function() self:VectorsToNearestMEDEVAC(group) end) - - -- MASH Locations - CMD('MASH Locations', medevacRoot, function() self:ListMASHLocations(group) end) - - -- Pop Smoke at Crew Locations - CMD('Pop Smoke at Crew Locations', medevacRoot, function() self:PopSmokeAtMEDEVACSites(group) end) - - -- Pop Smoke at MASH Zones - CMD('Pop Smoke at MASH Zones', medevacRoot, function() self:PopSmokeAtMASHZones(group) end) - - -- Duplicate guide from Admin/Help -> Player Guides for quick access - MENU_GROUP_COMMAND:New(group, 'MASH & Salvage System - Guide', medevacRoot, function() - local lines = {} - table.insert(lines, 'MASH & Salvage System - Player Guide') - table.insert(lines, '') - table.insert(lines, 'What is it?') - table.insert(lines, '- MASH (Mobile Army Surgical Hospital) zones accept MEDEVAC crew deliveries.') - table.insert(lines, '- When ground vehicles are destroyed, crews spawn nearby and call for rescue.') - table.insert(lines, '- Rescuing crews and delivering them to MASH earns Salvage Points for your coalition.') - table.insert(lines, '- Salvage Points let you build out-of-stock items, keeping logistics flowing.') - table.insert(lines, '') - table.insert(lines, 'How MEDEVAC works:') - table.insert(lines, '- Vehicle destroyed → crew spawns after delay with invulnerability period.') - table.insert(lines, '- MEDEVAC request announced with grid coordinates and salvage value.') - table.insert(lines, '- Crews have a time limit (default 60 minutes); failure = crew KIA and vehicle lost.') - table.insert(lines, '') - table.insert(lines, 'Pickup Methods:') - table.insert(lines, '- AUTO: Land within 500m of crew - they will run to you and board automatically!') - table.insert(lines, '- HOVER: Fly close, hover nearby, load troops normally - system detects MEDEVAC crew.') - table.insert(lines, '- Original vehicle respawns when crew is picked up (if enabled).') - table.insert(lines, '') - table.insert(lines, 'Delivering to MASH:') - table.insert(lines, '- AUTO: Land in any MASH zone - crews unload automatically after 2 seconds.') - table.insert(lines, '- MANUAL: Deploy troops inside MASH zone - salvage points awarded automatically.') - table.insert(lines, '- Coalition message shows points earned and new total.') - table.insert(lines, '') - table.insert(lines, 'Using Salvage Points:') - table.insert(lines, '- When crate requests fail (out of stock), salvage auto-applies if available.') - table.insert(lines, '- Each catalog item has a salvage cost (usually matches its value).') - table.insert(lines, '- Check current salvage: Coach & Nav -> MEDEVAC Status.') - table.insert(lines, '') - table.insert(lines, 'Mobile MASH:') - table.insert(lines, '- Build Mobile MASH crates to deploy field hospitals anywhere.') - table.insert(lines, '- Mobile MASH creates a new delivery zone with radio beacon.') - table.insert(lines, '- Multiple mobile MASHs can be deployed for forward operations.') - table.insert(lines, '') - table.insert(lines, 'Best practices:') - table.insert(lines, '- Monitor MEDEVAC requests: Coach & Nav -> Vectors to Nearest MEDEVAC Crew.') - table.insert(lines, '- Prioritize high-value vehicles (armor, AA) for maximum salvage.') - table.insert(lines, '- Deploy Mobile MASH near active combat zones to reduce delivery time.') - table.insert(lines, '- Coordinate with team: share MEDEVAC locations and salvage status.') - table.insert(lines, '- Watch for warnings: 15min and 5min alerts before crew timeout.') - MESSAGE:New(table.concat(lines, '\n'), 50):ToGroup(group) - end) - - -- Admin/Settings submenu - local medevacAdminRoot = MENU_GROUP:New(group, 'Admin/Settings', medevacRoot) - CMD('Clear All MEDEVAC Missions', medevacAdminRoot, function() self:ClearAllMEDEVACMissions(group) end) - end - - -- Operations -> FARP - if CTLD.FARPConfig and CTLD.FARPConfig.Enabled then - local farpRoot = MENU_GROUP:New(group, 'FARP', opsRoot) - - -- Upgrade FOB to FARP - CMD('Upgrade FOB to FARP', farpRoot, function() self:RequestFARPUpgrade(group) end) - - -- Show FARP Status - CMD('Show FARP Status', farpRoot, function() self:ShowFARPStatus(group) end) - - -- Show Salvage Points - CMD('Coalition Salvage Points', farpRoot, function() self:ShowSalvagePoints(group) end) - - -- FARP System Guide - MENU_GROUP_COMMAND:New(group, 'FARP System - Guide', farpRoot, function() - local lines = {} - table.insert(lines, 'FARP System - Player Guide') - table.insert(lines, '') - table.insert(lines, 'What is FARP?') - table.insert(lines, '- FARP = Forward Arming and Refueling Point') - table.insert(lines, '- Upgrade FOBs into operational FARPs with rearm/refuel capability') - table.insert(lines, '- Progressive stages add equipment and expand services') - table.insert(lines, '- Uses coalition salvage points earned from MEDEVAC and salvage collection') - table.insert(lines, '') - table.insert(lines, 'How to Upgrade:') - table.insert(lines, '1. Build a FOB using normal CTLD mechanics') - table.insert(lines, '2. Earn salvage points (deliver MEDEVAC crews to MASH, sling-load enemy wreckage)') - table.insert(lines, '3. Fly to the FOB pickup zone') - table.insert(lines, '4. Use: Operations -> FARP -> Upgrade FOB to FARP') - table.insert(lines, '5. Each upgrade costs salvage and adds new equipment/services') - table.insert(lines, '') - table.insert(lines, 'FARP Stages:') - table.insert(lines, '') - table.insert(lines, 'Stage 1: Basic FARP Pad (3 salvage)') - table.insert(lines, '- Landing pad with command post') - table.insert(lines, '- Personnel tents and basic supplies') - table.insert(lines, '- Fuel drums and generators') - table.insert(lines, '- Perimeter security (sandbags)') - table.insert(lines, '') - table.insert(lines, 'Stage 2: Operational FARP (5 salvage, 8 total)') - table.insert(lines, '- 2x HEMTT Fuel Trucks - REFUEL CAPABILITY!') - table.insert(lines, '- Large fuel bladders and storage') - table.insert(lines, '- Upgraded command post') - table.insert(lines, '- Defensive barriers (Hesco walls)') - table.insert(lines, '- Support vehicles and power distribution') - table.insert(lines, '- Expanded equipment and tools') - table.insert(lines, '') - table.insert(lines, 'Stage 3: Full Forward Airbase (8 salvage, 16 total)') - table.insert(lines, '- 2x Ammunition Trucks - REARM CAPABILITY!') - table.insert(lines, '- Communications tower (SKP-11 ATC)') - table.insert(lines, '- Large maintenance shelter') - table.insert(lines, '- Complete defensive perimeter') - table.insert(lines, '- Watch tower for security') - table.insert(lines, '- Multiple supply depots') - table.insert(lines, '- Vehicle park with support trucks') - table.insert(lines, '- Unit identification markers') - table.insert(lines, '- Full workshop facilities') - table.insert(lines, '') - table.insert(lines, 'Services Available:') - table.insert(lines, '- Stage 1: Landing zone only') - table.insert(lines, '- Stage 2: Refuel for helicopters & ground vehicles') - table.insert(lines, '- Stage 3: Rearm, Refuel, Repair for all units') - table.insert(lines, '') - table.insert(lines, 'Using FARPs:') - table.insert(lines, '- Land or park within service radius (50-80m depending on stage)') - table.insert(lines, '- Services are automatic for friendly units') - table.insert(lines, '- Helicopters can hover-refuel at Stage 2+') - table.insert(lines, '- Ground vehicles automatically rearm/refuel when stopped in zone') - table.insert(lines, '') - table.insert(lines, 'Strategy Tips:') - table.insert(lines, '- Build FOBs in strategic locations before upgrading') - table.insert(lines, '- Pool salvage as a team for critical FARP upgrades') - table.insert(lines, '- Upgrade forward FOBs to Stage 2 for quick helicopter turnaround') - table.insert(lines, '- Stage 3 FARPs support sustained ground operations') - table.insert(lines, '- Protect your FARPs - they become high-value targets!') - table.insert(lines, '- Check status before upgrading: Operations -> FARP -> Show FARP Status') - table.insert(lines, '') - table.insert(lines, 'Dual Coalition:') - table.insert(lines, '- Each coalition has separate salvage pools') - table.insert(lines, '- FARPs are coalition-specific and only service friendly units') - table.insert(lines, '- Capture enemy territory to deny their FARP network') - MESSAGE:New(table.concat(lines, '\n'), 60):ToGroup(group) - end) - end - - -- Operations (root) -> List JTAC Status (placed at bottom of Operations) - CMD('List JTAC Status', opsRoot, function() self:ListJTACStatus(group) end) - CMD('JTAC Diagnostics', opsRoot, function() self:JTACDiagnostics(group) end) - - -- Logistics -> Crates, Build, and Recipe Details - CMD('Show Onboard Manifest', logRoot, function() self:ShowOnboardManifest(group) end) - local reqRoot = MENU_GROUP:New(group, 'Request Crate', logRoot) - - local crateMgmt = MENU_GROUP:New(group, 'Crate Management', logRoot) - CMD('Drop One Loaded Crate', crateMgmt, function() self:DropLoadedCrates(group, 1) end) - CMD('Drop All Loaded Crates', crateMgmt, function() self:DropLoadedCrates(group, -1) end) - self:_BuildOrRefreshLoadedCrateMenu(group, crateMgmt) - CMD('Re-mark Nearest Crate (Smoke)', crateMgmt, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - local bestName, bestMeta, bestd - for name,meta in pairs(CTLD._crates) do - if meta.side == self.Side then - local dx = (meta.point.x - here.x) - local dz = (meta.point.z - here.z) - local d = math.sqrt(dx*dx + dz*dz) - if (not bestd) or d < bestd then - bestName, bestMeta, bestd = name, meta, d - end - end - end - if bestName and bestMeta then - local sx, sz = bestMeta.point.x, bestMeta.point.z - local sy = 0 - if land and land.getHeight then - local ok, h = pcall(land.getHeight, { x = sx, y = sz }) - if ok and type(h) == 'number' then sy = h end - end - local smokeColor = self.Config.PickupZoneSmokeColor - _spawnCrateSmoke({ x = sx, y = sy, z = sz }, smokeColor, self.Config.CrateSmoke, bestName) - _eventSend(self, group, nil, 'crate_re_marked', { id = bestName, mark = 'smoke' }) - else - _msgGroup(group, 'No friendly crates found to mark.') - end - end) - - local buildRoot = MENU_GROUP:New(group, 'Build Menu', logRoot) - local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 - local buildHereLabel - if cd <= 0 then - buildHereLabel = 'Build All Here' - else - buildHereLabel = string.format('Build Here (w/%ds throttle)', cd) - end - CMD(buildHereLabel, buildRoot, function() self:BuildAtGroup(group) end) - self:_BuildOrRefreshBuildAdvancedMenu(group, buildRoot) - MENU_GROUP_COMMAND:New(group, 'Refresh Buildable List', buildRoot, function() - self:_BuildOrRefreshBuildAdvancedMenu(group, buildRoot) - MESSAGE:New('Buildable list refreshed.', 6):ToGroup(group) - end) - - local infoRoot = MENU_GROUP:New(group, 'Recipe Info', logRoot) - if self.Config.UseCategorySubmenus then - local reqSubmenus = {} - local function getRequestSub(catLabel) - if not reqSubmenus[catLabel] then - reqSubmenus[catLabel] = MENU_GROUP:New(group, catLabel, reqRoot) - end - return reqSubmenus[catLabel] - end - - local infoSubs = {} - local function getInfoSub(catLabel) - if not infoSubs[catLabel] then - infoSubs[catLabel] = MENU_GROUP:New(group, catLabel, infoRoot) - end - return infoSubs[catLabel] - end - - local replacementQueue = {} - for key,def in pairs(self.Config.CrateCatalog) do - if not (def and def.hidden) then - local sideOk = (not def.side) or def.side == self.Side - if sideOk then - local catLabel = (def and def.menuCategory) or 'Other' - local reqParent = getRequestSub(catLabel) - local label = self:_formatMenuLabelWithCrates(key, def) - - if def and type(def.requires) == 'table' then - CMD(label, reqParent, function() self:RequestRecipeBundleForGroup(group, key) end) - for reqKey,_ in pairs(def.requires) do - local compDef = self.Config.CrateCatalog[reqKey] - local compSideOk = (not compDef) or (not compDef.side) or compDef.side == self.Side - if compDef and compDef.hidden and compSideOk then - local queue = replacementQueue[catLabel] - if not queue then - queue = { list = {}, seen = {} } - replacementQueue[catLabel] = queue - end - if not queue.seen[reqKey] then - queue.seen[reqKey] = true - table.insert(queue.list, { key = reqKey, def = compDef }) - end - end - end - else - CMD(label, reqParent, function() self:RequestCrateForGroup(group, key) end) - end - - local infoParent = getInfoSub(catLabel) - CMD((def and (def.menu or def.description)) or key, infoParent, function() - local text = self:_formatRecipeInfo(key, def) - _msgGroup(group, text) - end) - end - end - end - - for catLabel,queue in pairs(replacementQueue) do - if queue and queue.list and #queue.list > 0 then - table.sort(queue.list, function(a,b) - local la = (a.def and (a.def.menu or a.def.description)) or a.key - local lb = (b.def and (b.def.menu or b.def.description)) or b.key - return tostring(la) < tostring(lb) - end) - local reqParent = getRequestSub(catLabel) - local replMenu = MENU_GROUP:New(group, 'Replacement Crates', reqParent) - for _,entry in ipairs(queue.list) do - local replLabel = string.format('Replacement: %s', self:_formatMenuLabelWithCrates(entry.key, entry.def)) - CMD(replLabel, replMenu, function() self:RequestCrateForGroup(group, entry.key) end) - end - end - end - else - local replacementList = {} - local replacementSeen = {} - for key,def in pairs(self.Config.CrateCatalog) do - if not (def and def.hidden) then - local sideOk = (not def.side) or def.side == self.Side - if sideOk then - local label = self:_formatMenuLabelWithCrates(key, def) - if def and type(def.requires) == 'table' then - CMD(label, reqRoot, function() self:RequestRecipeBundleForGroup(group, key) end) - for reqKey,_ in pairs(def.requires) do - local compDef = self.Config.CrateCatalog[reqKey] - local compSideOk = (not compDef) or (not compDef.side) or compDef.side == self.Side - if compDef and compDef.hidden and compSideOk and not replacementSeen[reqKey] then - replacementSeen[reqKey] = true - table.insert(replacementList, { key = reqKey, def = compDef }) - end - end - else - CMD(label, reqRoot, function() self:RequestCrateForGroup(group, key) end) - end - - CMD((def and (def.menu or def.description)) or key, infoRoot, function() - local text = self:_formatRecipeInfo(key, def) - _msgGroup(group, text) - end) - end - end - end - - if #replacementList > 0 then - table.sort(replacementList, function(a,b) - local la = (a.def and (a.def.menu or a.def.description)) or a.key - local lb = (b.def and (b.def.menu or b.def.description)) or b.key - return tostring(la) < tostring(lb) - end) - local replMenu = MENU_GROUP:New(group, 'Replacement Crates', reqRoot) - for _,entry in ipairs(replacementList) do - local replLabel = string.format('Replacement: %s', self:_formatMenuLabelWithCrates(entry.key, entry.def)) - CMD(replLabel, replMenu, function() self:RequestCrateForGroup(group, entry.key) end) - end - end - end - - -- Logistics -> Show Inventory at Nearest Pickup Zone/FOB - CMD('Show Inventory at Nearest Zone', logRoot, function() self:ShowNearestZoneInventory(group) end) - - -- Field Tools - CMD('Create Drop Zone (AO)', toolsRoot, function() self:CreateDropZoneAtGroup(group) end) - - -- Salvage Collection Zones submenu - if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then - local salvageZoneRoot = MENU_GROUP:New(group, 'Salvage Collection Zones', toolsRoot) - CMD('Create Salvage Zone Here', salvageZoneRoot, function() self:CreateSalvageZoneAtGroup(group) end) - CMD('Show Active Salvage Zones', salvageZoneRoot, function() self:ShowActiveSalvageZones(group) end) - CMD('Retire Oldest Salvage Zone', salvageZoneRoot, function() self:RetireOldestDynamicSalvageZone(group) end) - -- Dynamic per-zone management will be added by _rebuildSalvageZoneMenus - end - - local smokeRoot = MENU_GROUP:New(group, 'Smoke My Location', toolsRoot) - local function smokeHere(color) - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local p = unit:GetPointVec3() - -- Use full Vec3 to ensure correct placement - trigger.action.smoke({ x = p.x, y = p.y, z = p.z }, color) - end - MENU_GROUP_COMMAND:New(group, 'Green', smokeRoot, function() smokeHere(trigger.smokeColor.Green) end) - MENU_GROUP_COMMAND:New(group, 'Red', smokeRoot, function() smokeHere(trigger.smokeColor.Red) end) - MENU_GROUP_COMMAND:New(group, 'White', smokeRoot, function() smokeHere(trigger.smokeColor.White) end) - MENU_GROUP_COMMAND:New(group, 'Orange', smokeRoot, function() smokeHere(trigger.smokeColor.Orange) end) - MENU_GROUP_COMMAND:New(group, 'Blue', smokeRoot, function() smokeHere(trigger.smokeColor.Blue) end) - - -- Navigation - local gname = group:GetName() - CMD('Request Vectors to Nearest Crate', navRoot, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - local bestName, bestMeta, bestd - for name,meta in pairs(CTLD._crates) do - if meta.side == self.Side then - local dx = (meta.point.x - here.x) - local dz = (meta.point.z - here.z) - local d = math.sqrt(dx*dx + dz*dz) - if (not bestd) or d < bestd then - bestName, bestMeta, bestd = name, meta, d - end - end - end - if bestName and bestMeta then - local brg = _bearingDeg(here, bestMeta.point) - local isMetric = _getPlayerIsMetric(unit) - local rngV, rngU = _fmtRange(bestd, isMetric) - _eventSend(self, group, nil, 'vectors_to_crate', { id = bestName, brg = brg, rng = rngV, rng_u = rngU }) - else - _msgGroup(group, 'No friendly crates found.') - end - end) - - -- Sling-Load Salvage vectors - if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then - CMD('Vectors to Nearest Salvage Crate', navRoot, function() self:ShowNearestSalvageCrate(group) end) - end - - CMD('Vectors to Nearest Pickup Zone', navRoot, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local zone = nil - local dist = nil - local list = nil - if self.Config and self.Config.Zones and self.Config.Zones.PickupZones then - list = {} - for _,z in ipairs(self.Config.Zones.PickupZones) do - if (not z.name) or self._ZoneActive.Pickup[z.name] ~= false then table.insert(list, z) end - end - elseif self.PickupZones and #self.PickupZones > 0 then - list = {} - for _,mz in ipairs(self.PickupZones) do - if mz and mz.GetName then - local n = mz:GetName() - if self._ZoneActive.Pickup[n] ~= false then table.insert(list, { name = n }) end - end - end - else - list = {} - end - zone, dist = _nearestZonePoint(unit, list) - if not zone then - local allDefs = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {} - if allDefs and #allDefs > 0 then - local fbZone, fbDist = _nearestZonePoint(unit, allDefs) - if fbZone then - local up = unit:GetPointVec3(); local zp = fbZone:GetPointVec3() - local from = { x = up.x, z = up.z } - local to = { x = zp.x, z = zp.z } - local brg = _bearingDeg(from, to) - local isMetric = _getPlayerIsMetric(unit) - local rngV, rngU = _fmtRange(fbDist or 0, isMetric) - _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = fbZone:GetName(), brg = brg, rng = rngV, rng_u = rngU }) - return - end - end - _eventSend(self, group, nil, 'no_pickup_zones', {}) - return - end - local up = unit:GetPointVec3() - local zp = zone:GetPointVec3() - local from = { x = up.x, z = up.z } - local to = { x = zp.x, z = zp.z } - local brg = _bearingDeg(from, to) - local isMetric = _getPlayerIsMetric(unit) - local rngV, rngU = _fmtRange(dist, isMetric) - _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = zone:GetName(), brg = brg, rng = rngV, rng_u = rngU }) - end) - - -- Navigation -> Smoke Nearest Zone (Pickup/Drop/FOB) - CMD('Smoke Nearest Zone (Pickup/Drop/FOB/MASH)', navRoot, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - -- Build lists of active zones by kind in a format usable by _nearestZonePoint - local function collectActive(kind) - if kind == 'Pickup' then - return self:_collectActivePickupDefs() - elseif kind == 'Drop' then - local out = {} - for _, mz in ipairs(self.DropZones or {}) do - if mz and mz.GetName then - local n = mz:GetName() - if (self._ZoneActive and self._ZoneActive.Drop and self._ZoneActive.Drop[n] ~= false) then - table.insert(out, { name = n }) - end - end - end - return out - elseif kind == 'FOB' then - local out = {} - for _, mz in ipairs(self.FOBZones or {}) do - if mz and mz.GetName then - local n = mz:GetName() - if (self._ZoneActive and self._ZoneActive.FOB and self._ZoneActive.FOB[n] ~= false) then - table.insert(out, { name = n }) - end - end - end - return out - elseif kind == 'MASH' then - local out = {} - if CTLD._mashZones then - for name, data in pairs(CTLD._mashZones) do - if data and data.side == self.Side and data.zone then - table.insert(out, { name = name }) - end - end - end - return out - end - return {} - end - - local bestKind, bestZone, bestDist - for _, k in ipairs({ 'Pickup', 'Drop', 'FOB', 'MASH' }) do - local list = collectActive(k) - if list and #list > 0 then - local z, d = _nearestZonePoint(unit, list) - if z and d and ((not bestDist) or d < bestDist) then - bestKind, bestZone, bestDist = k, z, d - end - end - end - - if not bestZone then - _msgGroup(group, 'No zones available to smoke.') - return - end - - -- Determine smoke point (zone center) - -- _getZoneCenterAndRadius returns (center, radius); call directly to capture center - local center - if self._getZoneCenterAndRadius then center = select(1, self:_getZoneCenterAndRadius(bestZone)) end - if not center then - local v3 = bestZone:GetPointVec3() - center = { x = v3.x, y = v3.y or 0, z = v3.z } - else - center = { x = center.x, y = center.y or 0, z = center.z } - end - - -- Choose smoke color per kind - local color = trigger.smokeColor.Green -- default - if bestKind == 'Pickup' then - color = self.Config.PickupZoneSmokeColor or trigger.smokeColor.Green - elseif bestKind == 'Drop' then - color = trigger.smokeColor.Red - elseif bestKind == 'FOB' then - color = trigger.smokeColor.White - elseif bestKind == 'MASH' then - color = trigger.smokeColor.Orange - end - - -- Apply smoke offset system (use crate smoke config settings) - local smokeConfig = self.Config.CrateSmoke or {} - local smokePos = { - x = center.x, - y = land.getHeight({x = center.x, y = center.z}), - z = center.z - } - local offsetMeters = tonumber(smokeConfig.OffsetMeters) or 5 - local offsetRandom = (smokeConfig.OffsetRandom ~= false) -- default true - local offsetVertical = tonumber(smokeConfig.OffsetVertical) or 2 - - if offsetMeters > 0 then - local angle = 0 - if offsetRandom then - angle = math.random() * 2 * math.pi - end - smokePos.x = smokePos.x + offsetMeters * math.cos(angle) - smokePos.z = smokePos.z + offsetMeters * math.sin(angle) - end - smokePos.y = smokePos.y + offsetVertical - - -- Use MOOSE COORDINATE smoke for better appearance (tall, thin smoke like cargo smoke) - local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) - if coord and coord.Smoke then - if color == trigger.smokeColor.Green then - coord:SmokeGreen() - elseif color == trigger.smokeColor.Red then - coord:SmokeRed() - elseif color == trigger.smokeColor.White then - coord:SmokeWhite() - elseif color == trigger.smokeColor.Orange then - coord:SmokeOrange() - elseif color == trigger.smokeColor.Blue then - coord:SmokeBlue() - else - coord:SmokeGreen() - end - local distKm = bestDist / 1000 - local distNm = bestDist / 1852 - _msgGroup(group, string.format('Smoked nearest %s zone: %s (%.1f km / %.1f nm)', bestKind, bestZone:GetName(), distKm, distNm)) - elseif trigger and trigger.action and trigger.action.smoke then - -- Fallback to trigger.action.smoke if MOOSE COORDINATE not available - trigger.action.smoke(smokePos, color) - local distKm = bestDist / 1000 - local distNm = bestDist / 1852 - _msgGroup(group, string.format('Smoked nearest %s zone: %s (%.1f km / %.1f nm)', bestKind, bestZone:GetName(), distKm, distNm)) - else - _msgGroup(group, 'Smoke not available in this environment.') - end - end) - - -- Smoke all nearby zones within range - CMD('Smoke All Nearby Zones (5km)', navRoot, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local maxRange = 5000 -- 5km in meters - - -- Get unit position - local uname = unit:GetName() - local du = Unit.getByName and Unit.getByName(uname) or nil - if not du or not du:getPoint() then - _msgGroup(group, 'Unable to determine your position.') - return - end - local up = du:getPoint() - local ux, uz = up.x, up.z - - -- Helper function to calculate distance and smoke a zone if in range - local function smokeZoneIfInRange(zoneName, zoneObj, zoneType, smokeColor) - if not zoneObj then return false end - - -- Get zone center - local center - if self._getZoneCenterAndRadius then - center = select(1, self:_getZoneCenterAndRadius(zoneObj)) - end - if not center and zoneObj.GetPointVec3 then - local v3 = zoneObj:GetPointVec3() - center = { x = v3.x, y = v3.y or 0, z = v3.z } - end - - if not center then return false end - - -- Calculate distance - local dx = center.x - ux - local dz = center.z - uz - local dist = math.sqrt(dx*dx + dz*dz) - - if dist <= maxRange then - -- Apply smoke offset system - local smokeConfig = self.Config.CrateSmoke or {} - local smokePos = { - x = center.x, - y = land.getHeight({x = center.x, y = center.z}), - z = center.z - } - local offsetMeters = tonumber(smokeConfig.OffsetMeters) or 5 - local offsetRandom = (smokeConfig.OffsetRandom ~= false) - local offsetVertical = tonumber(smokeConfig.OffsetVertical) or 2 - - if offsetMeters > 0 then - local angle = 0 - if offsetRandom then - angle = math.random() * 2 * math.pi - end - smokePos.x = smokePos.x + offsetMeters * math.cos(angle) - smokePos.z = smokePos.z + offsetMeters * math.sin(angle) - end - smokePos.y = smokePos.y + offsetVertical - - -- Spawn smoke - local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) - if coord and coord.Smoke then - if smokeColor == trigger.smokeColor.Green then - coord:SmokeGreen() - elseif smokeColor == trigger.smokeColor.Red then - coord:SmokeRed() - elseif smokeColor == trigger.smokeColor.White then - coord:SmokeWhite() - elseif smokeColor == trigger.smokeColor.Orange then - coord:SmokeOrange() - elseif smokeColor == trigger.smokeColor.Blue then - coord:SmokeBlue() - else - coord:SmokeGreen() - end - else - trigger.action.smoke(smokePos, smokeColor) - end - - return true, dist - end - - return false, dist - end - - -- Helper to get color name - local function getColorName(color) - if color == trigger.smokeColor.Green then return "Green" - elseif color == trigger.smokeColor.Red then return "Red" - elseif color == trigger.smokeColor.White then return "White" - elseif color == trigger.smokeColor.Orange then return "Orange" - elseif color == trigger.smokeColor.Blue then return "Blue" - else return "Unknown" end - end - - local count = 0 - local zones = {} - - -- Check Pickup zones - local pickupDefs = self:_collectActivePickupDefs() - for _, def in ipairs(pickupDefs or {}) do - local mz = _findZone(def) - if mz then - -- Check for zone-specific smoke override, then fall back to config default - local zdef = self._ZoneDefs and self._ZoneDefs.PickupZones and self._ZoneDefs.PickupZones[def.name] - local smokeColor = (zdef and zdef.smoke) or self.Config.PickupZoneSmokeColor or trigger.smokeColor.Green - local smoked, dist = smokeZoneIfInRange(def.name, mz, 'Pickup', smokeColor) - if smoked then - count = count + 1 - local zp = mz:GetPointVec3() - local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) - table.insert(zones, string.format('Pickup: %s - %.1f km @ %03d° (%s)', def.name, dist/1000, brg, getColorName(smokeColor))) - end - end - end - - -- Check Drop zones - for _, mz in ipairs(self.DropZones or {}) do - if mz and mz.GetName then - local n = mz:GetName() - if (self._ZoneActive and self._ZoneActive.Drop and self._ZoneActive.Drop[n] ~= false) then - local smokeColor = trigger.smokeColor.Red - local smoked, dist = smokeZoneIfInRange(n, mz, 'Drop', smokeColor) - if smoked then - count = count + 1 - local zp = mz:GetPointVec3() - local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) - table.insert(zones, string.format('Drop: %s - %.1f km @ %03d° (%s)', n, dist/1000, brg, getColorName(smokeColor))) - end - end - end - end - - -- Check FOB zones - for _, mz in ipairs(self.FOBZones or {}) do - if mz and mz.GetName then - local n = mz:GetName() - if (self._ZoneActive and self._ZoneActive.FOB and self._ZoneActive.FOB[n] ~= false) then - local smokeColor = trigger.smokeColor.White - local smoked, dist = smokeZoneIfInRange(n, mz, 'FOB', smokeColor) - if smoked then - count = count + 1 - local zp = mz:GetPointVec3() - local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) - table.insert(zones, string.format('FOB: %s - %.1f km @ %03d° (%s)', n, dist/1000, brg, getColorName(smokeColor))) - end - end - end - end - - -- Check MASH zones - if CTLD._mashZones then - for name, data in pairs(CTLD._mashZones) do - if data and data.side == self.Side and data.zone then - local smokeColor = trigger.smokeColor.Orange - local smoked, dist = smokeZoneIfInRange(name, data.zone, 'MASH', smokeColor) - if smoked then - count = count + 1 - local zp = data.zone:GetPointVec3() - local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) - table.insert(zones, string.format('MASH: %s - %.1f km @ %03d° (%s)', name, dist/1000, brg, getColorName(smokeColor))) - end - end - end - end - - -- Check Salvage Drop zones - for _, mz in ipairs(self.SalvageDropZones or {}) do - if mz and mz.GetName then - local n = mz:GetName() - local isActive = true - if self._ZoneActive and self._ZoneActive.SalvageDrop then - isActive = (self._ZoneActive.SalvageDrop[n] ~= false) - end - if isActive then - local zdef = self._ZoneDefs and self._ZoneDefs.SalvageDropZones and self._ZoneDefs.SalvageDropZones[n] - local smokeColor = (zdef and zdef.smoke) or trigger.smokeColor.Orange - local smoked, dist = smokeZoneIfInRange(n, mz, 'SalvageDrop', smokeColor) - if smoked then - count = count + 1 - local zp = mz:GetPointVec3() - local brg = _bearingDeg({ x = ux, z = uz }, { x = zp.x, z = zp.z }) - table.insert(zones, string.format('Salvage: %s - %.1f km @ %03d° (%s)', n, dist/1000, brg, getColorName(smokeColor))) - end - end - end - end - - if count == 0 then - _msgGroup(group, string.format('No zones found within %.1f km.', maxRange/1000), 10) - else - local msg = string.format('Smoked %d zone(s) within %.1f km:\n%s', count, maxRange/1000, table.concat(zones, '\n')) - _msgGroup(group, msg, 15) - end - end) - - -- Navigation -> MEDEVAC menu items (if MEDEVAC enabled) - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - CMD('Vectors to Nearest MEDEVAC Crew', navRoot, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local pos = unit:GetPointVec3() - local isMetric = _getPlayerIsMetric(unit) - local nearest = nil - local nearestDist = math.huge - - -- Find nearest crew of same coalition - for crewName, crewData in pairs(CTLD._medevacCrews or {}) do - if crewData.side == self.Side and not crewData.pickedUp then - local dx = crewData.position.x - pos.x - local dz = crewData.position.z - pos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist < nearestDist then - nearestDist = dist - nearest = crewData - end - end - end - - if not nearest then - _msgGroup(group, 'No active MEDEVAC requests.') - return - end - - local brg = _bearingDeg({ x = pos.x, z = pos.z }, { x = nearest.position.x, z = nearest.position.z }) - local v, u = _fmtRange(nearestDist, isMetric) - - -- Calculate time remaining until timeout - local cfg = CTLD.MEDEVAC - local timeoutAt = nearest.spawnTime + (cfg.CrewTimeout or 3600) - local timeRemain = math.max(0, math.floor((timeoutAt - timer.getTime()) / 60)) - - _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_vectors, { - vehicle = nearest.vehicleType, - brg = brg, - rng = v, - rng_u = u, - time_remain = timeRemain - })) - end) - - CMD('Vectors to Nearest MASH', navRoot, function() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local pos = unit:GetPointVec3() - local isMetric = _getPlayerIsMetric(unit) - local nearest = nil - local nearestDist = math.huge - - -- Find nearest MASH of same coalition - for _, mashData in pairs(CTLD._mashZones or {}) do - if mashData.side == self.Side then - local dx = mashData.position.x - pos.x - local dz = mashData.position.z - pos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist < nearestDist then - nearestDist = dist - nearest = mashData - end - end - end - - if not nearest then - _msgGroup(group, 'No active MASH zones.') - return - end - - local brg = _bearingDeg({ x = pos.x, z = pos.z }, { x = nearest.position.x, z = nearest.position.z }) - local v, u = _fmtRange(nearestDist, isMetric) - local mashName = nearest.isMobile and ('Mobile MASH ' .. (nearest.id:match('_(%d+)$') or '?')) or nearest.catalogKey - - _msgGroup(group, string.format('Nearest MASH: %s, bearing %d°, range %s %s', mashName, brg, v, u)) - end) - end - - -- Hover Coach (at end of Navigation submenu) - CMD('Hover Coach: Enable', navRoot, function() - CTLD._coachOverride = CTLD._coachOverride or {} - CTLD._coachOverride[gname] = true - _eventSend(self, group, nil, 'coach_enabled', {}) - end) - CMD('Hover Coach: Disable', navRoot, function() - CTLD._coachOverride = CTLD._coachOverride or {} - CTLD._coachOverride[gname] = false - _eventSend(self, group, nil, 'coach_disabled', {}) - end) - - -- Admin/Help - -- Status & map controls - CMD('Show CTLD Status', adminRoot, function() - local crates = 0 - for _ in pairs(CTLD._crates) do crates = crates + 1 end - local msg = string.format('CTLD Status:\nActive crates: %d\nPickup zones: %d\nDrop zones: %d\nFOB zones: %d\nBuild Confirm: %s (%ds window)\nBuild Cooldown: %s (%ds)' - , crates, #(self.PickupZones or {}), #(self.DropZones or {}), #(self.FOBZones or {}) - , self.Config.BuildConfirmEnabled and 'ON' or 'OFF', self.Config.BuildConfirmWindowSeconds or 0 - , self.Config.BuildCooldownEnabled and 'ON' or 'OFF', self.Config.BuildCooldownSeconds or 0) - - -- Add MEDEVAC info if enabled - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - local activeRequests = 0 - for _, data in pairs(CTLD._medevacCrews or {}) do - if data.side == self.Side and not data.pickedUp then - activeRequests = activeRequests + 1 - end - end - local salvage = CTLD._salvagePoints[self.Side] or 0 - local mashCount = 0 - for _, m in pairs(CTLD._mashZones or {}) do - if m.side == self.Side then mashCount = mashCount + 1 end - end - msg = msg .. string.format('\n\nMEDEVAC:\nActive requests: %d\nMASH zones: %d\nSalvage points: %d', - activeRequests, mashCount, salvage) - end - - MESSAGE:New(msg, 20):ToGroup(group) - end) - CMD('Draw CTLD Zones on Map', adminRoot, function() - self:DrawZonesOnMap() - MESSAGE:New('CTLD zones drawn on F10 map.', 8):ToGroup(group) - end) - CMD('Clear CTLD Map Drawings', adminRoot, function() - self:ClearMapDrawings() - MESSAGE:New('CTLD map drawings cleared.', 8):ToGroup(group) - end) - - -- MEDEVAC Statistics (if enabled) - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CMD('Show MEDEVAC Statistics', adminRoot, function() - local stats = CTLD._medevacStats[self.Side] or {} - local lines = {} - table.insert(lines, 'MEDEVAC Statistics:') - table.insert(lines, '') - table.insert(lines, string.format('Crews spawned: %d', stats.spawned or 0)) - table.insert(lines, string.format('Crews rescued: %d', stats.rescued or 0)) - table.insert(lines, string.format('Delivered to MASH: %d', stats.delivered or 0)) - table.insert(lines, string.format('Timed out: %d', stats.timedOut or 0)) - table.insert(lines, string.format('Killed in action: %d', stats.killed or 0)) - table.insert(lines, '') - table.insert(lines, string.format('Vehicles respawned: %d', stats.vehiclesRespawned or 0)) - table.insert(lines, string.format('Salvage earned: %d', stats.salvageEarned or 0)) - table.insert(lines, string.format('Salvage used: %d', stats.salvageUsed or 0)) - table.insert(lines, string.format('Current salvage: %d', CTLD._salvagePoints[self.Side] or 0)) - - MESSAGE:New(table.concat(lines, '\n'), 30):ToGroup(group) - end) - end - - -- Admin/Help -> Debug - local debugMenu = MENU_GROUP:New(group, 'Debug', adminRoot) - CMD('Enable verbose logging', debugMenu, function() - self.Config.LogLevel = LOG_DEBUG - _logInfo(string.format('[%s] Verbose/Debug logging ENABLED via Admin menu', tostring(self.Side))) - MESSAGE:New('CTLD verbose logging ENABLED (LogLevel=4)', 8):ToGroup(group) - end) - CMD('Normal logging (INFO)', debugMenu, function() - self.Config.LogLevel = LOG_INFO - _logInfo(string.format('[%s] Logging set to INFO level via Admin menu', tostring(self.Side))) - MESSAGE:New('CTLD logging set to INFO (LogLevel=2)', 8):ToGroup(group) - end) - CMD('Minimal logging (ERRORS only)', debugMenu, function() - self.Config.LogLevel = LOG_ERROR - _logInfo(string.format('[%s] Logging set to ERROR-only via Admin menu', tostring(self.Side))) - MESSAGE:New('CTLD logging set to ERRORS only (LogLevel=1)', 8):ToGroup(group) - end) - CMD('Disable all logging', debugMenu, function() - self.Config.LogLevel = LOG_NONE - MESSAGE:New('CTLD logging DISABLED (LogLevel=0)', 8):ToGroup(group) - end) - - -- Admin/Help -> Player Guides (moved earlier) - - return root -end - --- Create or refresh the filtered "In Stock Here" menu for a group. --- If rootMenu is provided, (re)create under that. Otherwise, reuse previous stored root. -function CTLD:_BuildOrRefreshInStockMenu(group, rootMenu) - if not (self.Config.Inventory and self.Config.Inventory.Enabled and self.Config.Inventory.HideZeroStockMenu) then return end - if not group or not group:IsAlive() then return end - local gname = group:GetName() - -- remove previous menu if present and rootMenu not explicitly provided - local existing = CTLD._inStockMenus[gname] - if existing and existing.menu and (rootMenu == nil) then - pcall(function() existing.menu:Remove() end) - CTLD._inStockMenus[gname] = nil - end - - local parent = rootMenu or (self.MenusByGroup and self.MenusByGroup[gname]) - if not parent then return end - - -- Create a fresh submenu root - local inRoot = MENU_GROUP:New(group, 'Request Crate (In Stock Here)', parent) - CTLD._inStockMenus[gname] = { menu = inRoot } - - -- Find nearest active pickup zone - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local zone, dist = self:_nearestActivePickupZone(unit) - if not zone then - MENU_GROUP_COMMAND:New(group, 'No active supply zone nearby', inRoot, function() - -- Inform and also provide vectors to nearest configured zone if any - _eventSend(self, group, nil, 'no_pickup_zones', {}) - -- Fallback: try any configured pickup zone (ignoring active state) for helpful vectors - local list = self.Config and self.Config.Zones and self.Config.Zones.PickupZones or {} - if list and #list > 0 then - local unit = group:GetUnit(1) - if unit and unit:IsAlive() then - local fallbackZone, fallbackDist = _nearestZonePoint(unit, list) - if fallbackZone then - local up = unit:GetPointVec3(); local zp = fallbackZone:GetPointVec3() - local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) - local isMetric = _getPlayerIsMetric(unit) - local rngV, rngU = _fmtRange(fallbackDist or 0, isMetric) - _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = fallbackZone:GetName(), brg = brg, rng = rngV, rng_u = rngU }) - end - end - end - end) - -- Still add a refresh item - MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end) - return - end - local zname = zone:GetName() - local maxd = self.Config.PickupZoneMaxDistance or 10000 - if not dist or dist > maxd then - MENU_GROUP_COMMAND:New(group, string.format('Nearest zone %s is beyond limit (%.0f m).', zname, dist or 0), inRoot, function() - local isMetric = _getPlayerIsMetric(unit) - local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric) - local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() - local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) - _eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) - end) - MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end) - return - end - - -- Info and refresh commands at top - MENU_GROUP_COMMAND:New(group, string.format('Nearest Supply: %s', zname), inRoot, function() - local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() - local brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) - local isMetric = _getPlayerIsMetric(unit) - local rngV, rngU = _fmtRange(dist or 0, isMetric) - _eventSend(self, group, nil, 'vectors_to_pickup_zone', { zone = zname, brg = brg, rng = rngV, rng_u = rngU }) - end) - MENU_GROUP_COMMAND:New(group, 'Refresh In-Stock List', inRoot, function() self:_BuildOrRefreshInStockMenu(group) end) - - -- Build commands for items with stock > 0 at this zone; single-unit entries only - local inStock = {} - local stock = CTLD._stockByZone[zname] or {} - for key,def in pairs(self.Config.CrateCatalog or {}) do - local sideOk = (not def.side) or def.side == self.Side - local isSingle = (type(def.requires) ~= 'table') - if sideOk and isSingle then - local cnt = tonumber(stock[key] or 0) or 0 - if cnt > 0 then - table.insert(inStock, { key = key, def = def, cnt = cnt }) - end - end - end - -- Stable sort by menu label for consistency - table.sort(inStock, function(a,b) - local la = (a.def and (a.def.menu or a.def.description)) or a.key - local lb = (b.def and (b.def.menu or b.def.description)) or b.key - return tostring(la) < tostring(lb) - end) - - if #inStock == 0 then - MENU_GROUP_COMMAND:New(group, 'None in stock at this zone', inRoot, function() - _msgGroup(group, string.format('No crates in stock at %s.', zname)) - end) - else - for _,it in ipairs(inStock) do - local base = (it.def and (it.def.menu or it.def.description)) or it.key - local total = self:_recipeTotalCrates(it.def) - local suffix = (total == 1) and '1 crate' or (tostring(total)..' crates') - local label = string.format('%s (%s) [%d available]', base, suffix, it.cnt) - MENU_GROUP_COMMAND:New(group, label, inRoot, function() - self:RequestCrateForGroup(group, it.key) - -- After requesting, refresh to reflect the decremented stock - local id = timer.scheduleFunction(function() self:_BuildOrRefreshInStockMenu(group) end, {}, timer.getTime() + 0.1) - _trackOneShotTimer(id) - end) - end - end -end - --- Create or refresh the dynamic Build (Advanced) menu for a group. -function CTLD:_BuildOrRefreshBuildAdvancedMenu(group, rootMenu) - if not group or not group:IsAlive() then return end - -- Clear previous dynamic children if any by recreating the submenu root when rootMenu passed - -- We'll remove and recreate inner items by making a temporary child root - local gname = group:GetName() - -- Remove existing dynamic children by creating a fresh inner menu under the provided root - local dynRoot = MENU_GROUP:New(group, 'Buildable Near You', rootMenu) - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - local hdgRad, _ = _headingRadDeg(unit) - local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0) - local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdgRad) * buildOffset, z = here.z + math.cos(hdgRad) * buildOffset } or { x = here.x, z = here.z } - local radius = self.Config.BuildRadius or 100 - local nearby = self:GetNearbyCrates(here, radius) - local filtered = {} - for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end - nearby = filtered - -- Count by key - local counts = {} - for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end - -- Include carried crates if allowed - if self.Config.BuildRequiresGroundCrates ~= true then - local gname = group:GetName() - local carried = CTLD._loadedCrates[gname] - if carried and carried.byKey then - for k,v in pairs(carried.byKey) do counts[k] = (counts[k] or 0) + v end - end - end - -- FOB restriction context - local insideFOBZone = select(1, self:IsPointInFOBZones(here)) - - -- Build list of buildable recipes - local items = {} - for key,cat in pairs(self.Config.CrateCatalog or {}) do - local sideOk = (not cat.side) or cat.side == self.Side - if sideOk and cat and cat.build then - local ok = false - if type(cat.requires) == 'table' then - ok = true - for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < (qty or 0) then ok = false; break end end - else - ok = ((counts[key] or 0) >= (cat.required or 1)) - end - if ok then - if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone) then - table.insert(items, { key = key, def = cat }) - end - end - end - end - - if #items == 0 then - MENU_GROUP_COMMAND:New(group, 'None buildable here. Drop required crates close to your aircraft.', dynRoot, function() - MESSAGE:New('No buildable items with nearby crates. Use Recipe Info to check requirements.', 10):ToGroup(group) - end) - return - end - - -- Stable ordering by label - table.sort(items, function(a,b) - local la = (a.def and (a.def.menu or a.def.description)) or a.key - local lb = (b.def and (b.def.menu or b.def.description)) or b.key - return tostring(la) < tostring(lb) - end) - - -- Create per-item submenus - local function CMD(title, parent, cb) - return MENU_GROUP_COMMAND:New(group, title, parent, function() - local ok, err = pcall(cb) - if not ok then _logVerbose('BuildAdv menu error: '..tostring(err)); MESSAGE:New('CTLD menu error: '..tostring(err), 8):ToGroup(group) end - end) - end - - for _,it in ipairs(items) do - local label = (it.def and (it.def.menu or it.def.description)) or it.key - local perItem = MENU_GROUP:New(group, label, dynRoot) - local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 - local holdTitle, attackTitle - if cd <= 0 then - holdTitle = 'Build All [Hold Position]' - attackTitle = string.format('Build All [Attack (%dm)]', (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000) - else - holdTitle = string.format('Build (w/%ds throttle) [Hold Position]', cd) - attackTitle = string.format('Build (w/%ds throttle) [Attack (%dm)]', cd, (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000) - end - -- Hold Position - CMD(holdTitle, perItem, function() - self:BuildSpecificAtGroup(group, it.key, { behavior = 'defend' }) - end) - -- Attack variant (render even if canAttackMove=false; we message accordingly) - local vr = (self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000 - CMD(attackTitle, perItem, function() - if it.def and it.def.canAttackMove == false then - MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group) - self:BuildSpecificAtGroup(group, it.key, { behavior = 'defend' }) - else - self:BuildSpecificAtGroup(group, it.key, { behavior = 'attack' }) - end - end) - end -end - --- Build a specific recipe at the group position if crates permit; supports behavior opts -function CTLD:BuildSpecificAtGroup(group, recipeKey, opts) - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local ctld = self - -- Reuse Build cooldown/confirm logic - local now = timer.getTime() - local gname = group:GetName() - if self.Config.BuildCooldownEnabled then - local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 - if cd > 0 then - local last = CTLD._buildCooldown[gname] - if last and (now - last) < cd then - local rem = math.max(0, math.ceil(cd - (now - last))) - _msgGroup(group, string.format('Build on cooldown. Try again in %ds.', rem)) - return - end - end - end - if self.Config.BuildConfirmEnabled then - local first = CTLD._buildConfirm[gname] - local win = self.Config.BuildConfirmWindowSeconds or 10 - if not first or (now - first) > win then - CTLD._buildConfirm[gname] = now - _msgGroup(group, string.format('Confirm build: select again within %ds to proceed.', win)) - return - else - CTLD._buildConfirm[gname] = nil - end - end - - local def = self.Config.CrateCatalog[recipeKey] - if not def or not def.build then _msgGroup(group, 'Unknown or unbuildable recipe: '..tostring(recipeKey)); return end - - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - local hdgRad, hdgDeg = _headingRadDeg(unit) - local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0) - local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdgRad) * buildOffset, z = here.z + math.cos(hdgRad) * buildOffset } or { x = here.x, z = here.z } - local radius = self.Config.BuildRadius or 100 - local nearby = self:GetNearbyCrates(here, radius) - local filtered = {} - for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end - nearby = filtered - if #nearby == 0 and self.Config.BuildRequiresGroundCrates ~= true then - -- still can build using carried crates - elseif #nearby == 0 then - _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) - return - end - - -- Count by key - local counts = {} - for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end - -- Include carried crates - local carried = CTLD._loadedCrates[gname] - if self.Config.BuildRequiresGroundCrates ~= true then - if carried and carried.byKey then for k,v in pairs(carried.byKey) do counts[k] = (counts[k] or 0) + v end end - end - - -- Helper to consume crates of a given key/qty (prefers carried when allowed) - local function consumeCrates(key, qty) - local removed = 0 - if self.Config.BuildRequiresGroundCrates ~= true then - if carried and carried.byKey and (carried.byKey[key] or 0) > 0 then - local take = math.min(qty, carried.byKey[key]) - carried.byKey[key] = carried.byKey[key] - take - if carried.byKey[key] <= 0 then carried.byKey[key] = nil end - carried.total = math.max(0, (carried.total or 0) - take) - removed = removed + take - if take > 0 then ctld:_scheduleLoadedCrateMenuRefresh(group) end - end - end - for _,c in ipairs(nearby) do - if removed >= qty then break end - if c.meta.key == key then - local obj = StaticObject.getByName(c.name) - if obj then obj:destroy() end - _cleanupCrateSmoke(c.name) -- Clean up smoke refresh schedule - if c.meta and c.meta.point then - _removeFromSpatialGrid(c.name, c.meta.point, 'crate') -- prune hover pickup spatial cache - end - CTLD._crates[c.name] = nil - removed = removed + 1 - end - end - end - - -- FOB restriction check - if def.isFOB and self.Config.RestrictFOBToZones then - local inside = select(1, self:IsPointInFOBZones(here)) - if not inside then _eventSend(self, group, nil, 'fob_restricted', {}); return end - end - - -- Special-case: SAM Site Repair/Augment entries (isRepair) - if def.isRepair == true or tostring(recipeKey):find('_REPAIR', 1, true) then - -- Map recipe key family to a template definition - local function identifyTemplate(key) - if key:find('HAWK', 1, true) then - return { - name='HAWK', side=def.side or self.Side, - baseUnits={ {type='Hawk sr', dx=12, dz=8}, {type='Hawk tr', dx=-12, dz=8}, {type='Hawk pcp', dx=18, dz=12}, {type='Hawk cwar', dx=-18, dz=12} }, - launcherType='Hawk ln', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=6 - } - elseif key:find('PATRIOT', 1, true) then - return { - name='PATRIOT', side=def.side or self.Side, - baseUnits={ {type='Patriot str', dx=14, dz=10}, {type='Patriot ECS', dx=-14, dz=10} }, - launcherType='Patriot ln', launcherStart={dx=0, dz=0}, launcherStep={dx=8, dz=0}, maxLaunchers=6 - } - elseif key:find('KUB', 1, true) then - return { - name='KUB', side=def.side or self.Side, - baseUnits={ {type='Kub 1S91 str', dx=12, dz=8} }, - launcherType='Kub 2P25 ln', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=3 - } - elseif key:find('BUK', 1, true) then - return { - name='BUK', side=def.side or self.Side, - baseUnits={ {type='SA-11 Buk SR 9S18M1', dx=12, dz=8}, {type='SA-11 Buk CC 9S470M1', dx=-12, dz=8} }, - launcherType='SA-11 Buk LN 9A310M1', launcherStart={dx=0, dz=0}, launcherStep={dx=6, dz=0}, maxLaunchers=6 - } - end - return nil - end - - local tpl = identifyTemplate(tostring(recipeKey)) - if not tpl then _msgGroup(group, 'No matching SAM site type for repair: '..tostring(recipeKey)); return end - - -- Determine how many repair crates to apply - local cratesAvail = counts[recipeKey] or 0 - if cratesAvail <= 0 then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end - - -- Find nearest existing site group that matches template - local function vec2(u) - local p = u:getPoint(); return { x = p.x, z = p.z } - end - local function dist2(a,b) - local dx, dz = a.x-b.x, a.z-b.z; return math.sqrt(dx*dx+dz*dz) - end - local searchR = math.max(250, (self.Config.BuildRadius or 100) * 10) - local groups = coalition.getGroups(tpl.side, Group.Category.GROUND) or {} - local here2 = { x = here.x, z = here.z } - local bestG, bestD, bestInfo = nil, 1e9, nil - for _,g in ipairs(groups) do - if g and g:isExist() then - local units = g:getUnits() or {} - if #units > 0 then - -- Compute center and count types - local cx, cz = 0, 0 - local byType = {} - for _,u in ipairs(units) do - local pt = u:getPoint(); cx = cx + pt.x; cz = cz + pt.z - local tname = u:getTypeName() or '' - byType[tname] = (byType[tname] or 0) + 1 - end - cx = cx / #units; cz = cz / #units - local d = dist2(here2, { x = cx, z = cz }) - if d <= searchR then - -- Check presence of base units (at least 1 each) - local ok = true - for _,u in ipairs(tpl.baseUnits) do if (byType[u.type] or 0) < 1 then ok = false; break end end - -- Require at least 1 launcher or allow 0 (initial repair to full base)? we'll allow 0 too. - if ok then - if d < bestD then - bestG, bestD = g, d - bestInfo = { byType = byType, center = { x = cx, z = cz }, headingDeg = function() - local h = 0; local leader = units[1]; if leader and leader.isExist and leader:isExist() then h = math.deg(leader:getHeading() or 0) end; return h - end } - end - end - end - end - end - end - - if not bestG then - _msgGroup(group, 'No matching SAM site found nearby to repair/augment.') - return - end - - -- Current launchers in site - local curLaunchers = (bestInfo.byType[tpl.launcherType] or 0) - local maxL = tpl.maxLaunchers or (curLaunchers + cratesAvail) - local canAdd = math.max(0, (maxL - curLaunchers)) - if canAdd <= 0 then - _msgGroup(group, 'SAM site is already at max launchers.') - return - end - local addNum = math.min(cratesAvail, canAdd) - - -- Build new group composition: base units + (curLaunchers + addNum) launchers - local function buildSite(point, headingDeg, side, launcherCount) - local hdg = math.rad(headingDeg or 0) - local function off(dx, dz) - -- rotate offsets by heading - local s, c = math.sin(hdg), math.cos(hdg) - local rx = dx * c + dz * s - local rz = -dx * s + dz * c - return { x = point.x + rx, z = point.z + rz } - end - local units = {} - -- Place launchers in a row starting at launcherStart and stepping by launcherStep - for i=0, (launcherCount-1) do - local dx = (tpl.launcherStart.dx or 0) + (tpl.launcherStep.dx or 0) * i - local dz = (tpl.launcherStart.dz or 0) + (tpl.launcherStep.dz or 0) * i - local p = off(dx, dz) - table.insert(units, { type = tpl.launcherType, name = string.format('CTLD-%s-%d', tpl.launcherType, math.random(100000,999999)), x = p.x, y = p.z, heading = hdg }) - end - -- Place base units at their template offsets - for _,u in ipairs(tpl.baseUnits) do - local p = off(u.dx or 0, u.dz or 0) - table.insert(units, { type = u.type, name = string.format('CTLD-%s-%d', u.type, math.random(100000,999999)), x = p.x, y = p.z, heading = hdg }) - end - return { visible=false, lateActivation=false, tasks={}, task='Ground Nothing', route={}, units=units, name=string.format('CTLD_SITE_%d', math.random(100000,999999)) } - end - - _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) - -- Destroy old group, spawn new one - local oldName = bestG:getName() - local newLauncherCount = curLaunchers + addNum - local center = bestInfo.center - local headingDeg = bestInfo.headingDeg() - if Group.getByName(oldName) then pcall(function() Group.getByName(oldName):destroy() end) end - local gdata = buildSite({ x = center.x, z = center.z }, headingDeg, tpl.side, newLauncherCount) - local newG = _coalitionAddGroup(tpl.side, Group.Category.GROUND, gdata, self.Config) - if not newG then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end - -- Consume used repair crates - consumeCrates(recipeKey, addNum) - _eventSend(self, nil, self.Side, 'build_success_coalition', { build = (def.description or recipeKey), player = _playerNameFromGroup(group) }) - if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end - return - end - - -- Verify counts and build (supports multi-build when cooldown is zero) - if type(def.requires) == 'table' then - local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 - local maxCopies = 1 - if cd <= 0 then - maxCopies = math.huge - for reqKey,qty in pairs(def.requires) do - if (qty or 0) > 0 then - local available = counts[reqKey] or 0 - local copiesForKey = math.floor(available / (qty or 1)) - if copiesForKey < maxCopies then maxCopies = copiesForKey end - end - end - if maxCopies < 1 or maxCopies == math.huge then - _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) - return - end - else - for reqKey,qty in pairs(def.requires) do - if (counts[reqKey] or 0) < (qty or 0) then - _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) - return - end - end - maxCopies = 1 - end - - local built = 0 - while built < maxCopies do - local gdata = def.build({ x = spawnAt.x, z = spawnAt.z }, hdgDeg, def.side or self.Side) - _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) - local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata, self.Config) - if not g then - _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) - break - end - if self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC pre: post-build (composite) key=%s group=%s', tostring(recipeKey), tostring(g:getName()))) - end - self:_maybeRegisterJTAC(recipeKey, def, g) - for reqKey,qty in pairs(def.requires) do - consumeCrates(reqKey, qty or 0) - counts[reqKey] = (counts[reqKey] or 0) - (qty or 0) - end - _eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) }) - if def.isFOB then pcall(function() self:_CreateFOBPickupZone({ x = spawnAt.x, z = spawnAt.z }, def, hdg) end) end - if def.isMobileMASH then - _logDebug(string.format('[MobileMASH] BuildSpecificAtGroup invoking _CreateMobileMASH for key %s at (%.1f, %.1f)', tostring(recipeKey), spawnAt.x or -1, spawnAt.z or -1)) - local ok, err = pcall(function() self:_CreateMobileMASH(g, { x = spawnAt.x, z = spawnAt.z }, def) end) - if not ok then - _logError(string.format('[MobileMASH] _CreateMobileMASH invocation failed: %s', tostring(err))) - end - end - -- behavior (applied for each built group) - local behavior = opts and opts.behavior or nil - if behavior == 'attack' and (def.canAttackMove ~= false) and self.Config.AttackAI and self.Config.AttackAI.Enabled then - local t = self:_assignAttackBehavior(g:getName(), spawnAt, true) - local isMetric = _getPlayerIsMetric(group:GetUnit(1)) - if t and t.kind == 'base' then - local brg = _bearingDeg(spawnAt, t.point) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) - elseif t and t.kind == 'enemy' then - local brg = _bearingDeg(spawnAt, t.point) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) - else - local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) - _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) - end - elseif behavior == 'attack' and def.canAttackMove == false then - MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group) - end - - built = built + 1 - if cd > 0 then break end - end - - if self.Config.BuildCooldownEnabled and cd > 0 then CTLD._buildCooldown[gname] = now end - return - else - -- single-key - local need = def.required or 1 - local cd = tonumber(self.Config.BuildCooldownSeconds) or 0 - local maxCopies = 1 - if cd <= 0 then - local available = counts[recipeKey] or 0 - maxCopies = math.floor(available / need) - if maxCopies < 1 then - _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }) - return - end - else - if (counts[recipeKey] or 0) < need then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end - maxCopies = 1 - end - - local built = 0 - while built < maxCopies do - local gdata = def.build({ x = spawnAt.x, z = spawnAt.z }, hdgDeg, def.side or self.Side) - _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) - local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata, self.Config) - if not g then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); break end - if self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC pre: post-build (single) key=%s group=%s', tostring(recipeKey), tostring(g:getName()))) - end - self:_maybeRegisterJTAC(recipeKey, def, g) - consumeCrates(recipeKey, need) - counts[recipeKey] = (counts[recipeKey] or 0) - need - _eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) }) - -- behavior - local behavior = opts and opts.behavior or nil - if behavior == 'attack' and (def.canAttackMove ~= false) and self.Config.AttackAI and self.Config.AttackAI.Enabled then - local t = self:_assignAttackBehavior(g:getName(), spawnAt, true) - local isMetric = _getPlayerIsMetric(group:GetUnit(1)) - if t and t.kind == 'base' then - local brg = _bearingDeg(spawnAt, t.point) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) - elseif t and t.kind == 'enemy' then - local brg = _bearingDeg(spawnAt, t.point) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) - else - local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) - _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) - end - elseif behavior == 'attack' and def.canAttackMove == false then - MESSAGE:New('This unit is static or not suited to move; it will hold position.', 8):ToGroup(group) - end - - built = built + 1 - if cd > 0 then break end - end - - if self.Config.BuildCooldownEnabled and cd > 0 then CTLD._buildCooldown[gname] = now end - return - end -end - -function CTLD:_definitionIsJTAC(def) - if not def then return false end - if def.isJTAC == true then return true end - if type(def.jtac) == 'table' and def.jtac.enabled ~= false then return true end - if type(def.roles) == 'table' then - for _, role in ipairs(def.roles) do - if tostring(role):upper() == 'JTAC' then - return true - end - end - end - return false -end - -function CTLD:_maybeRegisterJTAC(recipeKey, def, dcsGroup) - if not (self.Config.JTAC and self.Config.JTAC.Enabled) then - if self.Config and self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo('JTAC check: JTAC disabled in config; skipping registration') - end - return - end - if not self:_definitionIsJTAC(def) then - if self.Config and self.Config.JTAC and self.Config.JTAC.Verbose then - local hasRoles = (def and type(def.roles) == 'table') and table.concat((function(r) local t={} for i,v in ipairs(r) do t[i]=tostring(v) end return t end)(def.roles),'|') or '(none)' - local hasJTAC = (def and type(def.jtac) == 'table') and 'yes' or 'no' - _logInfo(string.format('JTAC check: definition not JTAC. key=%s jtacTable=%s roles=%s isJTAC=%s', tostring(recipeKey), hasJTAC, hasRoles, tostring(def and def.isJTAC))) - end - return - end - if not dcsGroup then - if self.Config and self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC check: no DCS group to register. key=%s', tostring(recipeKey))) - end - return - end - if self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC check: attempting registration. key=%s unitType=%s group=%s', tostring(recipeKey), tostring(def and def.unitType or def and def.description or 'n/a'), tostring(dcsGroup and dcsGroup.getName and dcsGroup:getName() or ''))) - end - self:_registerJTACGroup(recipeKey, def, dcsGroup) -end - -function CTLD:_reserveJTACCode(side, groupName) - local pool = self.Config.JTAC and self.Config.JTAC.LaserCodes or { '1688' } - if not CTLD._jtacReservedCodes then - CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } - end - CTLD._jtacReservedCodes[side] = CTLD._jtacReservedCodes[side] or {} - for _, code in ipairs(pool) do - code = tostring(code) - if not CTLD._jtacReservedCodes[side][code] then - CTLD._jtacReservedCodes[side][code] = groupName - return code - end - end - local fallback = tostring(pool[1] or '1688') - _logVerbose(string.format('JTAC laser code pool exhausted for side %s, reusing %s', tostring(side), fallback)) - return fallback -end - -function CTLD:_releaseJTACCode(side, code, groupName) - if not code then return end - code = tostring(code) - if CTLD._jtacReservedCodes and CTLD._jtacReservedCodes[side] then - if CTLD._jtacReservedCodes[side][code] == groupName then - CTLD._jtacReservedCodes[side][code] = nil - end - end -end - -function CTLD:_registerJTACGroup(recipeKey, def, dcsGroup) - if not (dcsGroup and dcsGroup.getName) then return end - local groupName = dcsGroup:getName() - if not groupName then return end - - self:_cleanupJTACEntry(groupName) -- ensure stale entry cleared - - local side = dcsGroup:getCoalition() or self.Side - local code = self:_reserveJTACCode(side, groupName) - local platform = 'ground' - if def and def.jtac and def.jtac.platform then - platform = tostring(def.jtac.platform) - elseif def and def.category == Group.Category.AIRPLANE then - platform = 'air' - end - local cfgSmoke = self.Config.JTAC and self.Config.JTAC.Smoke or {} - local smokeColor = (side == coalition.side.BLUE) and cfgSmoke.ColorBlue or cfgSmoke.ColorRed - - local entry = { - groupName = groupName, - recipeKey = recipeKey, - def = def, - side = side, - code = code, - platform = platform, - smokeColor = smokeColor, - nextScan = timer.getTime() + 2, - smokeNext = 0, - lockType = def and def.jtac and def.jtac.lock, - } - - local friendlyName = (def and self:_friendlyNameForKey(recipeKey)) or groupName - entry.displayName = friendlyName - entry.lastState = 'onstation' - - self._jtacRegistry[groupName] = entry - - self:_announceJTAC('jtac_onstation', entry, { - jtac = friendlyName, - code = code, - }) - - _logInfo(string.format('JTAC registered: group=%s friendlyName=%s code=%s platform=%s verbose=%s', tostring(groupName), tostring(friendlyName), tostring(code), tostring(platform), tostring(self.Config.JTAC and self.Config.JTAC.Verbose))) -end - -function CTLD:_announceJTAC(msgKey, entry, payload) - if not entry then return end - local cfg = self.Config.JTAC and self.Config.JTAC.Announcements - local allowed = true - if entry and entry.announceOverride ~= nil then - allowed = entry.announceOverride == true - else - allowed = (cfg and cfg.Enabled ~= false) - end - if not allowed then return end - local tpl = CTLD.Messages[msgKey] - if not tpl then return end - local data = payload or {} - data.jtac = data.jtac or entry.displayName or entry.groupName - data.code = data.code or entry.code - local text = _fmtTemplate(tpl, data) - if text and text ~= '' then - _msgCoalition(entry.side or self.Side, text, cfg.Duration or self.Config.MessageDuration) - end -end - -function CTLD:_cleanupJTACEntry(groupName) - local entry = self._jtacRegistry and self._jtacRegistry[groupName] - if not entry then return end - self:_cancelJTACSpots(entry) - self:_releaseJTACCode(entry.side or self.Side, entry.code, groupName) - self._jtacRegistry[groupName] = nil -end - -function CTLD:_cancelJTACSpots(entry) - if not entry then return end - if entry.laserSpot then - pcall(function() Spot.destroy(entry.laserSpot) end) - entry.laserSpot = nil - end - if entry.irSpot then - pcall(function() Spot.destroy(entry.irSpot) end) - entry.irSpot = nil - end -end - -function CTLD:_tickJTACs() - if not self._jtacRegistry then return end - if not next(self._jtacRegistry) then return end - local now = timer.getTime() - for groupName, entry in pairs(self._jtacRegistry) do - if not entry.nextScan or now >= entry.nextScan then - local ok, err = pcall(function() - self:_processJTACEntry(groupName, entry, now) - end) - if not ok then - _logError(string.format('JTAC tick error for %s: %s', tostring(groupName), tostring(err))) - entry.nextScan = now + 10 - end - end - end -end - -function CTLD:_processJTACEntry(groupName, entry, now) - local cfg = self.Config.JTAC or {} - local autoCfg = cfg.AutoLase or {} - if autoCfg.Enabled == false then - self:_cancelJTACSpots(entry) - entry.nextScan = now + 30 - return - end - if entry.paused then - self:_cancelJTACSpots(entry) - entry.nextScan = now + 30 - entry.lastState = 'paused' - return - end - local group = Group.getByName(groupName) - if not group or not group:isExist() then - self:_cleanupJTACEntry(groupName) - return - end - - local units = group:getUnits() or {} - if #units == 0 then - self:_cancelJTACSpots(entry) - entry.nextScan = now + (autoCfg.TransportHoldSeconds or 10) - return - end - - local jtacUnit = units[1] - if not jtacUnit or jtacUnit:getLife() <= 0 or not jtacUnit:isActive() then - self:_cleanupJTACEntry(groupName) - return - end - - entry.jtacUnitName = entry.jtacUnitName or jtacUnit:getName() - entry.displayName = entry.displayName or entry.jtacUnitName or groupName - - local jtacPoint = jtacUnit:getPoint() - local searchRadius = tonumber(entry.searchRadiusOverride or autoCfg.SearchRadius) or 8000 - if cfg.Verbose then - _logInfo(string.format('JTAC tick: group=%s unit=%s radius=%.0f pos=(%.0f,%.0f,%.0f)', tostring(groupName), tostring(entry.jtacUnitName or jtacUnit:getName()), searchRadius, jtacPoint.x or -1, jtacPoint.y or -1, jtacPoint.z or -1)) - end - - local current = entry.currentTarget - local targetUnit = nil - local targetStatus = nil - - if current and current.name then - local candidate = Unit.getByName(current.name) - if candidate and candidate:isExist() and candidate:getLife() > 0 then - local tgtPoint = candidate:getPoint() - local dist = _distance3d(tgtPoint, jtacPoint) - if dist <= searchRadius and _hasLineOfSight(jtacPoint, tgtPoint) then - targetUnit = candidate - current.lastSeen = now - current.distance = dist - else - targetStatus = 'lost' - end - else - targetStatus = 'destroyed' - end - if targetStatus then - if targetStatus == 'destroyed' then - if entry.lastState ~= 'destroyed' then - self:_announceJTAC('jtac_target_destroyed', entry, { - jtac = entry.displayName, - target = current.label or current.name, - code = entry.code, - }) - entry.lastState = 'destroyed' - end - else - if entry.lastState ~= 'lost' then - self:_announceJTAC('jtac_target_lost', entry, { - jtac = entry.displayName, - target = current.label or current.name, - }) - entry.lastState = 'lost' - end - end - entry.currentTarget = nil - targetUnit = nil - self:_cancelJTACSpots(entry) - entry.nextScan = now + (targetStatus == 'lost' and (autoCfg.LostRetrySeconds or 10) or 5) - end - end - - if not targetUnit then - local lockPref = entry.lockType or cfg.LockType or 'all' - local selection = self:_findJTACNewTarget(entry, jtacPoint, searchRadius, lockPref) - if cfg.Verbose then - _logInfo(string.format('JTAC scan: group=%s lock=%s found=%s', tostring(groupName), tostring(lockPref), selection and (selection.unit and selection.unit:getTypeName()) or 'nil')) - end - if selection then - targetUnit = selection.unit - entry.currentTarget = { - name = targetUnit:getName(), - label = targetUnit:getTypeName(), - firstSeen = now, - lastSeen = now, - distance = selection.distance, - } - local grid = self:_GetMGRSString(targetUnit:getPoint()) - local newState = 'target:'..(entry.currentTarget.name or '') - if entry.lastState ~= newState then - self:_announceJTAC('jtac_new_target', entry, { - jtac = entry.displayName, - target = targetUnit:getTypeName(), - code = entry.code, - grid = grid, - }) - entry.lastState = newState - end - end - end - - if targetUnit then - self:_updateJTACSpots(entry, jtacUnit, targetUnit) - entry.nextScan = now + (autoCfg.RefreshSeconds or 15) - if cfg.Verbose then - _logInfo(string.format('JTAC lase: group=%s target=%s code=%s', tostring(groupName), tostring(targetUnit and targetUnit:getTypeName()), tostring(entry.code))) - end - else - self:_cancelJTACSpots(entry) - entry.nextScan = now + (autoCfg.IdleRescanSeconds or 30) - if entry.lastState ~= 'idle' then - self:_announceJTAC('jtac_idle', entry, { - jtac = entry.displayName, - }) - entry.lastState = 'idle' - end - end -end - -function CTLD:ListJTACStatus(group) - local lines = {} - table.insert(lines, 'JTAC Status') - table.insert(lines, '') - if not self._jtacRegistry or not next(self._jtacRegistry) then - table.insert(lines, '(none registered)') - else - local now = timer.getTime() - for gname, entry in pairs(self._jtacRegistry) do - local tgt = entry.currentTarget and entry.currentTarget.label or '(idle)' - local age = entry.currentTarget and (now - (entry.currentTarget.firstSeen or now)) or 0 - local nextScan = entry.nextScan and (entry.nextScan - now) or -1 - table.insert(lines, string.format('- %s code=%s plat=%s state=%s target=%s age=%.0fs nextScan=%.0fs', - entry.displayName or gname, tostring(entry.code), tostring(entry.platform), tostring(entry.lastState), tgt, age, nextScan)) - end - end - local text = table.concat(lines, '\n') - if group and group:IsAlive() then - MESSAGE:New(text, 20):ToGroup(group) - else - _msgCoalition(self.Side, text, 20) - end -end - -function CTLD:JTACDiagnostics(group) - local lines = {} - table.insert(lines, 'JTAC Diagnostics') - local cfg = self.Config.JTAC or {} - table.insert(lines, string.format('Enabled=%s Verbose=%s LockType=%s', tostring(cfg.Enabled), tostring(cfg.Verbose), tostring(cfg.LockType))) - local auto = cfg.AutoLase or {} - table.insert(lines, string.format('AutoLase Enabled=%s Radius=%s Refresh=%s IdleRescan=%s LostRetry=%s', tostring(auto.Enabled), tostring(auto.SearchRadius), tostring(auto.RefreshSeconds), tostring(auto.IdleRescanSeconds), tostring(auto.LostRetrySeconds))) - local countCatalog = 0 - local jtacKeys = {} - for key,def in pairs(self.Config.CrateCatalog or {}) do - if self:_definitionIsJTAC(def) then - countCatalog = countCatalog + 1 - table.insert(jtacKeys, key) - end - end - table.insert(lines, string.format('Catalog JTAC Definitions: %d', countCatalog)) - if #jtacKeys > 0 then - table.insert(lines, 'Keys: '..table.concat(jtacKeys, ', ')) - end - local regCount = 0 - for _ in pairs(self._jtacRegistry or {}) do regCount = regCount + 1 end - table.insert(lines, string.format('Registered JTAC Groups: %d', regCount)) - if regCount > 0 then - for gname, entry in pairs(self._jtacRegistry) do - table.insert(lines, string.format(' Reg: %s code=%s state=%s', gname, tostring(entry.code), tostring(entry.lastState))) - end - end - local text = table.concat(lines, '\n') - if group and group:IsAlive() then - MESSAGE:New(text, 25):ToGroup(group) - else - _msgCoalition(self.Side, text, 25) - end -end - --- ========================= --- JTAC Controls (per-group active selection) --- ========================= - -function CTLD:_getActiveJTAC(group) - local gname = group and group:GetName() - if not gname then return nil end - CTLD._activeJTACByGroup = CTLD._activeJTACByGroup or {} - local key = CTLD._activeJTACByGroup[gname] - if key and self._jtacRegistry and self._jtacRegistry[key] then - return self._jtacRegistry[key] - end - return nil -end - -local function _unitVec2(unit) - local p = unit:GetPointVec3(); return { x = p.x, z = p.z } -end - -function CTLD:JTAC_SelectActiveForGroup(group, opts) - local entries = {} - for name, entry in pairs(self._jtacRegistry or {}) do table.insert(entries, entry) end - if #entries == 0 then MESSAGE:New('No JTACs registered yet.', 8):ToGroup(group); return end - -- choose nearest to player unit - table.sort(entries, function(a,b) - local u = group:GetUnit(1); if not u then return false end - local up = _unitVec2(group:GetUnit(1)) - local function d(e) - local g = Group.getByName(e.groupName); if not g then return 1e12 end - local gu = g:getUnits(); if not gu or #gu==0 then return 1e12 end - local p = gu[1]:getPoint(); local dx = (p.x - up.x); local dz=(p.z - up.z); return math.sqrt(dx*dx+dz*dz) - end - return d(a) < d(b) - end) - local chosen = entries[1] - CTLD._activeJTACByGroup = CTLD._activeJTACByGroup or {} - CTLD._activeJTACByGroup[group:GetName()] = chosen.groupName - MESSAGE:New(string.format('Active JTAC set to %s (code %s).', chosen.displayName or chosen.groupName, tostring(chosen.code)), 10):ToGroup(group) -end - -function CTLD:JTAC_TogglePause(group) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - e.paused = not e.paused - local msg = e.paused and 'paused' or 'resumed' - MESSAGE:New(string.format('JTAC %s %s.', e.displayName or e.groupName, msg), 8):ToGroup(group) -end - -function CTLD:JTAC_ReleaseTarget(group) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - self:_cancelJTACSpots(e) - e.currentTarget = nil - e.nextScan = timer.getTime() + 1 - e.lastState = 'released' - MESSAGE:New('JTAC target released.', 6):ToGroup(group) -end - -function CTLD:JTAC_ForceRescan(group) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - e.currentTarget = nil - e.nextScan = timer.getTime() + 0.5 - e.lastState = 'rescan' - MESSAGE:New('JTAC rescan queued.', 6):ToGroup(group) -end - -function CTLD:JTAC_SetLockFilter(group, mode) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - e.lockType = (mode or 'all') - e.currentTarget = nil; e.nextScan = timer.getTime() + 0.5 - MESSAGE:New(string.format('JTAC lock filter set to %s.', mode), 6):ToGroup(group) -end - -function CTLD:JTAC_SetPriority(group, profile) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - e.priorityProfile = profile or 'balanced' - e.currentTarget = nil; e.nextScan = timer.getTime() + 0.5 - MESSAGE:New(string.format('JTAC priority set: %s', profile), 6):ToGroup(group) -end - -function CTLD:JTAC_SetSearchRadius(group, meters) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - e.searchRadiusOverride = tonumber(meters) - e.currentTarget = nil; e.nextScan = timer.getTime() + 0.5 - MESSAGE:New(string.format('JTAC search radius set to %dm.', meters or 0), 6):ToGroup(group) -end - -function CTLD:JTAC_ToggleSmoke(group) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - if e.smokeEnabledOverride == nil then - e.smokeEnabledOverride = not ((self.Config.JTAC and self.Config.JTAC.Smoke and self.Config.JTAC.Smoke.Enabled) ~= false) - else - e.smokeEnabledOverride = not e.smokeEnabledOverride - end - local state = e.smokeEnabledOverride and 'ON' or 'OFF' - MESSAGE:New('JTAC smoke '..state..'.', 6):ToGroup(group) -end - -function CTLD:JTAC_SetSmokeColor(group, which) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - if which == 'blue' then - e.smokeColor = trigger.smokeColor.Blue - elseif which == 'orange' then - e.smokeColor = trigger.smokeColor.Orange - end - MESSAGE:New('JTAC smoke color set.', 6):ToGroup(group) -end - -function CTLD:JTAC_ToggleAnnouncements(group) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - if e.announceOverride == nil then - local cfg = self.Config.JTAC and self.Config.JTAC.Announcements - e.announceOverride = not (cfg and cfg.Enabled ~= false) - else - e.announceOverride = not e.announceOverride - end - MESSAGE:New('JTAC announcements '..(e.announceOverride and 'ON' or 'OFF')..'.', 6):ToGroup(group) -end - -function CTLD:JTAC_MarkCurrentTarget(group) - local e = self:_getActiveJTAC(group); if not e then self:JTAC_SelectActiveForGroup(group); e=self:_getActiveJTAC(group) end; if not e then return end - if not e.currentTarget or not e.currentTarget.name then MESSAGE:New('No current target to mark.', 6):ToGroup(group); return end - local u = Unit.getByName(e.currentTarget.name); if not u or not u:isExist() then MESSAGE:New('Target no longer valid.', 6):ToGroup(group); return end - local p = u:getPoint() - CTLD._markId = (CTLD._markId or 900000) + 1 - local text = string.format('JTAC %s target: %s (code %s)', e.displayName or e.groupName, e.currentTarget.label or e.currentTarget.name, tostring(e.code)) - local side = (group and group.GetCoalition and group:GetCoalition()) or e.side or coalition.side.BLUE - pcall(function() trigger.action.markToCoalition(CTLD._markId, text, {x=p.x, y=p.y, z=p.z}, side) end) - MESSAGE:New('Marked current target on map.', 6):ToGroup(group) -end - -function CTLD:_findJTACNewTarget(entry, jtacPoint, radius, lockType) - local enemy = _enemySide(entry and entry.side or self.Side) - local best - local lock = (lockType or 'all'):lower() - local profile = entry and entry.priorityProfile or 'balanced' - local ok, groups = pcall(function() - return coalition.getGroups(enemy, Group.Category.GROUND) or {} - end) - if not ok then - groups = {} - end - - for _, grp in ipairs(groups) do - if grp and grp:isExist() then - local units = grp:getUnits() - if units then - for _, unit in ipairs(units) do - if unit and unit:isExist() and unit:isActive() and unit:getLife() > 0 then - local skip = false - if lock == 'troop' and not _isDcsInfantry(unit) then skip = true end - if lock == 'vehicle' and _isDcsInfantry(unit) then skip = true end - if not skip then - local pos = unit:getPoint() - local dist = _distance3d(pos, jtacPoint) - if dist <= radius and _hasLineOfSight(jtacPoint, pos) then - local score = _jtacTargetScoreProfiled(unit, profile) - if not best or score > best.score or (score == best.score and dist < best.distance) then - best = { unit = unit, score = score, distance = dist } - end - end - end - end - end - end - end - end - - return best -end - -function CTLD:_updateJTACSpots(entry, jtacUnit, targetUnit) - if not (entry and jtacUnit and targetUnit) then return end - local codeNumber = tonumber(entry.code) or 1688 - local targetPoint = targetUnit:getPoint() - targetPoint = _vec3(targetPoint.x, targetPoint.y + 2.0, targetPoint.z) - local origin = { x = 0, y = 2.0, z = 0 } - - if not entry.laserSpot or not entry.irSpot then - local ok, res = pcall(function() - local spots = {} - spots.ir = Spot.createInfraRed(jtacUnit, origin, targetPoint) - spots.laser = Spot.createLaser(jtacUnit, origin, targetPoint, codeNumber) - return spots - end) - if ok and res then - entry.irSpot = entry.irSpot or res.ir - entry.laserSpot = entry.laserSpot or res.laser - else - _logError(string.format('JTAC spot create failed for %s: %s', tostring(entry.groupName), tostring(res))) - end - else - pcall(function() - if entry.laserSpot and entry.laserSpot.setPoint then entry.laserSpot:setPoint(targetPoint) end - if entry.irSpot and entry.irSpot.setPoint then entry.irSpot:setPoint(targetPoint) end - end) - end - - local smokeCfg = self.Config.JTAC and self.Config.JTAC.Smoke or {} - local smokeAllowed = (entry.smokeEnabledOverride ~= nil) and (entry.smokeEnabledOverride == true) or (entry.smokeEnabledOverride == nil and smokeCfg.Enabled) - if smokeAllowed then - local now = timer.getTime() - if not entry.smokeNext or now >= entry.smokeNext then - local color = entry.smokeColor or smokeCfg.ColorBlue or trigger.smokeColor.White - local pos = targetUnit:getPoint() - local offset = tonumber(smokeCfg.OffsetMeters) or 0 - if offset > 0 then - local ang = math.random() * math.pi * 2 - pos.x = pos.x + math.cos(ang) * offset - pos.z = pos.z + math.sin(ang) * offset - end - pcall(function() - trigger.action.smoke({ x = pos.x, y = pos.y, z = pos.z }, color) - end) - entry.smokeNext = now + (smokeCfg.RefreshSeconds or 300) - end - end -end - -function CTLD:BuildCoalitionMenus(root) - -- Optional: implement coalition-level crate spawns at pickup zones - for key,_ in pairs(self.Config.CrateCatalog) do - MENU_COALITION_COMMAND:New(self.Side, 'Spawn '..key..' at nearest Pickup Zone', root, function() - -- Not group-context; skip here - _msgCoalition(self.Side, 'Group menus recommended for crate requests') - end) - end -end - -function CTLD:InitCoalitionAdminMenu() - if self.AdminMenu then return end - -- Ensure we have a coalition-level CTLD parent menu to nest Admin/Help under - local rootCaption = (self.Config and self.Config.UseGroupMenus) and 'CTLD Admin' or 'CTLD' - self.MenuRoot = self.MenuRoot or MENU_COALITION:New(self.Side, rootCaption) - local root = MENU_COALITION:New(self.Side, 'Admin/Help', self.MenuRoot) - -- Player Help submenu (moved to top of Admin/Help) - local helpMenu = MENU_COALITION:New(self.Side, 'Player Help', root) - -- Removed standalone "Repair - How To" in favor of consolidated SAM Sites help - MENU_COALITION_COMMAND:New(self.Side, 'Zones - Guide', helpMenu, function() - local lines = {} - table.insert(lines, 'CTLD Zones - Guide') - table.insert(lines, '') - table.insert(lines, 'Zone types:') - table.insert(lines, '- Pickup (Supply): Request crates and load troops here. Crate requests require proximity to an ACTIVE pickup zone (default within 10 km).') - table.insert(lines, '- Drop: Mission-defined delivery or rally areas. Some missions may require delivery or deployment at these zones (see briefing).') - table.insert(lines, '- FOB: Forward Operating Base areas. Some recipes (FOB Site) can be built here; if FOB restriction is enabled, FOB-only builds must be inside an FOB zone.') - table.insert(lines, '') - table.insert(lines, 'Colors and map marks:') - table.insert(lines, '- Pickup zone crate spawns are marked with smoke in the configured color. Admin/Help -> Draw CTLD Zones on Map draws zone circles and labels on F10.') - table.insert(lines, '- Use Admin/Help -> Clear CTLD Map Drawings to remove the drawings. Drawings are read-only if configured.') - table.insert(lines, '') - table.insert(lines, 'How to use zones:') - table.insert(lines, '- To request crates: move within the pickup zone distance and use CTLD -> Request Crate.') - table.insert(lines, '- To load troops: must be inside a Pickup zone if troop loading restriction is enabled.') - table.insert(lines, '- Navigation: CTLD -> Coach & Nav -> Vectors to Nearest Pickup Zone gives bearing and range.') - table.insert(lines, '- Activation: Zones can be active/inactive per mission logic; inactive pickup zones block crate requests.') - table.insert(lines, '') - table.insert(lines, string.format('- Build Radius: about %d m to collect nearby crates when building.', self.Config.BuildRadius or 100)) - table.insert(lines, string.format('- Pickup Zone Max Distance: about %d m to request crates (configurable).', self.Config.PickupZoneMaxDistance or 10000)) - _msgCoalition(self.Side, table.concat(lines, '\n'), 40) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Inventory - How It Works', helpMenu, function() - local inv = self.Config.Inventory or {} - local enabled = inv.Enabled ~= false - local showHint = inv.ShowStockInMenu == true - local fobPct = math.floor(((inv.FOBStockFactor or 0.25) * 100) + 0.5) - local lines = {} - table.insert(lines, 'CTLD Inventory - How It Works') - table.insert(lines, '') - table.insert(lines, 'Overview:') - table.insert(lines, '- Inventory is tracked per Supply (Pickup) Zone and per FOB. Requests consume stock at that location.') - table.insert(lines, string.format('- Inventory is %s.', enabled and 'ENABLED' or 'DISABLED')) - table.insert(lines, '') - table.insert(lines, 'Starting stock:') - table.insert(lines, '- Each configured Supply Zone is seeded from the catalog initialStock for every crate type at mission start.') - table.insert(lines, string.format('- When you build a FOB, it creates a small Supply Zone with stock seeded at ~%d%% of initialStock.', fobPct)) - table.insert(lines, '') - table.insert(lines, 'Requesting crates:') - table.insert(lines, '- You must be within range of an ACTIVE Supply Zone to request crates; stock is decremented on spawn.') - table.insert(lines, '- If out of stock for a type at that zone, requests are denied for that type until resupplied (mission logic).') - table.insert(lines, '') - table.insert(lines, 'UI hints:') - table.insert(lines, string.format('- Show stock in menu labels: %s.', showHint and 'ON' or 'OFF')) - table.insert(lines, '- Some missions may include an "In Stock Here" list showing only items available at the nearest zone.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 40) - end) - MENU_COALITION_COMMAND:New(self.Side, 'CTLD Basics (2-minute tour)', helpMenu, function() - local isMetric = true - local lines = {} - table.insert(lines, 'CTLD Basics - 2 minute tour') - table.insert(lines, '') - table.insert(lines, 'Loop: Request -> Deliver -> Build -> Fight') - table.insert(lines, '- Request crates at an ACTIVE Supply Zone (Pickup).') - table.insert(lines, '- Deliver crates to the build point (within Build Radius).') - table.insert(lines, '- Build units or sites with "Build Here" (confirm + cooldown).') - table.insert(lines, '- Optional: set Attack or Defend behavior when building.') - table.insert(lines, '') - table.insert(lines, 'Key concepts:') - table.insert(lines, '- Zones: Pickup (supply), Drop (mission targets), FOB (forward supply).') - table.insert(lines, '- Inventory: stock is tracked per zone; requests consume stock there.') - table.insert(lines, '- FOBs: building one creates a local supply point with seeded stock.') - table.insert(lines, '- Advanced: SAM site repair crates, AI attack orders, EWR/JTAC support.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 35) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Troop Transport & JTAC Use', helpMenu, function() - local lines = {} - table.insert(lines, 'Troop Transport & JTAC Use') - table.insert(lines, '') - table.insert(lines, 'Troops:') - table.insert(lines, '- Load inside an ACTIVE Supply Zone (if mission enforces it).') - table.insert(lines, '- Deploy with Defend (hold) or Attack (advance to targets/bases).') - table.insert(lines, '- Attack uses a search radius and moves at configured speed.') - table.insert(lines, '') - table.insert(lines, 'JTAC:') - table.insert(lines, '- Build JTAC units (MRAP/Tigr or drones) to support target marking.') - table.insert(lines, '- JTAC helps with target designation/SA; details depend on mission setup.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 35) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Crates 101: Requesting and Handling', helpMenu, function() - local lines = {} - table.insert(lines, 'Crates 101 - Requesting and Handling') - table.insert(lines, '') - table.insert(lines, '- Request crates near an ACTIVE Supply Zone; max distance is configurable.') - table.insert(lines, '- Menu labels show the total crates required for a recipe.') - table.insert(lines, '- Drop crates close together but avoid overlap; smoke marks spawns.') - table.insert(lines, '- Use Coach & Nav tools: vectors to nearest pickup zone, re-mark crate with smoke.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 35) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Hover Pickup & Slingloading', helpMenu, function() - local coachCfg = CTLD.HoverCoachConfig or {} - local aglMin = (coachCfg.thresholds and coachCfg.thresholds.aglMin) or 5 - local aglMax = (coachCfg.thresholds and coachCfg.thresholds.aglMax) or 20 - local capGS = (coachCfg.thresholds and coachCfg.thresholds.captureGS) or (4/3.6) - local hold = (coachCfg.thresholds and coachCfg.thresholds.stabilityHold) or 1.8 - local lines = {} - table.insert(lines, 'Hover Pickup & Slingloading') - table.insert(lines, '') - table.insert(lines, string.format('- Hover pickup: hold AGL %d-%d m, speed < %.1f m/s, for ~%.1f s to auto-load.', aglMin, aglMax, capGS, hold)) - table.insert(lines, '- Keep steady within ~15 m of the crate; Hover Coach gives cues if enabled.') - table.insert(lines, '- Slingloading tips: avoid rotor wash over stacks; approach from upwind; re-mark crate with smoke if needed.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 35) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Build System: Build Here and Advanced', helpMenu, function() - local br = self.Config.BuildRadius or 100 - local win = self.Config.BuildConfirmWindowSeconds or 10 - local cd = self.Config.BuildCooldownSeconds or 60 - local lines = {} - table.insert(lines, 'Build System - Build Here and Advanced') - table.insert(lines, '') - table.insert(lines, string.format('- Build Here collects crates within ~%d m. Double-press within %d s to confirm.', br, win)) - table.insert(lines, string.format('- Cooldown: about %d s per group after a successful build.', cd)) - table.insert(lines, '- Advanced Build lets you choose Defend (hold) or Attack (move).') - table.insert(lines, '- Static or unsuitable units will hold even if Attack is chosen.') - table.insert(lines, '- FOB-only recipes must be inside an FOB zone when restriction is enabled.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 40) - end) - MENU_COALITION_COMMAND:New(self.Side, 'FOBs: Forward Supply & Why They Matter', helpMenu, function() - local fobPct = math.floor(((self.Config.Inventory and self.Config.Inventory.FOBStockFactor or 0.25) * 100) + 0.5) - local lines = {} - table.insert(lines, 'FOBs - Forward Supply and Why They Matter') - table.insert(lines, '') - table.insert(lines, '- Build a FOB by assembling its crate recipe (see Recipe Info).') - table.insert(lines, string.format('- A new local Supply Zone is created and seeded at ~%d%% of initial stock.', fobPct)) - table.insert(lines, '- FOBs shorten logistics legs and increase throughput toward the front.') - table.insert(lines, '- If enabled, FOB-only builds must occur inside FOB zones.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 35) - end) - MENU_COALITION_COMMAND:New(self.Side, 'SAM Sites: Building, Repairing, and Augmenting', helpMenu, function() - local br = self.Config.BuildRadius or 100 - local lines = {} - table.insert(lines, 'SAM Sites - Building, Repairing, and Augmenting') - table.insert(lines, '') - table.insert(lines, 'Build:') - table.insert(lines, '- Assemble site recipes using the required component crates (see menu labels). Build Here will place the full site.') - table.insert(lines, '') - table.insert(lines, 'Repair/Augment (merged):') - table.insert(lines, '- Request the matching "Repair/Launcher +1" crate for your site type (HAWK, Patriot, KUB, BUK).') - table.insert(lines, string.format('- Drop repair crate(s) within ~%d m of the site, then use Build Here (confirm window applies).', br)) - table.insert(lines, '- The nearest matching site (within a local search) is respawned fully repaired; +1 launcher per crate, up to caps.') - table.insert(lines, '- Caps: HAWK 6, Patriot 6, KUB 3, BUK 6. Extra crates beyond the cap are not consumed.') - table.insert(lines, '- Must match coalition and site type; otherwise no changes are applied.') - table.insert(lines, '- Respawn is required to apply repairs/augmentation due to DCS limitations.') - table.insert(lines, '') - table.insert(lines, 'Placement tips:') - table.insert(lines, '- Space launchers to avoid masking; keep radars with good line-of-sight; avoid fratricide arcs.') - _msgCoalition(self.Side, table.concat(lines, '\n'), 45) - end) - - -- Debug logging controls - local debugMenu = MENU_COALITION:New(self.Side, 'Debug Logging', root) - MENU_COALITION_COMMAND:New(self.Side, 'Enable Verbose (LogLevel 4)', debugMenu, function() - self.Config.LogLevel = LOG_DEBUG - _logInfo(string.format('[%s] Verbose/Debug logging ENABLED via Admin menu', tostring(self.Side))) - _msgCoalition(self.Side, 'CTLD verbose logging ENABLED (LogLevel=4)', 8) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Normal INFO (LogLevel 2)', debugMenu, function() - self.Config.LogLevel = LOG_INFO - _logInfo(string.format('[%s] Logging set to INFO level via Admin menu', tostring(self.Side))) - _msgCoalition(self.Side, 'CTLD logging set to INFO (LogLevel=2)', 8) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Errors Only (LogLevel 1)', debugMenu, function() - self.Config.LogLevel = LOG_ERROR - _logInfo(string.format('[%s] Logging set to ERROR-only via Admin menu', tostring(self.Side))) - _msgCoalition(self.Side, 'CTLD logging: ERRORS only (LogLevel=1)', 8) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Disable All (LogLevel 0)', debugMenu, function() - self.Config.LogLevel = LOG_NONE - _msgCoalition(self.Side, 'CTLD logging DISABLED (LogLevel=0)', 8) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Show CTLD Status (crates/zones)', root, function() - local crates = 0 - for _ in pairs(CTLD._crates) do crates = crates + 1 end - local msg = string.format('CTLD Status:\nActive crates: %d\nPickup zones: %d\nDrop zones: %d\nFOB zones: %d\nBuild Confirm: %s (%ds window)\nBuild Cooldown: %s (%ds)' - , crates, #(self.PickupZones or {}), #(self.DropZones or {}), #(self.FOBZones or {}) - , self.Config.BuildConfirmEnabled and 'ON' or 'OFF', self.Config.BuildConfirmWindowSeconds or 0 - , self.Config.BuildCooldownEnabled and 'ON' or 'OFF', self.Config.BuildCooldownSeconds or 0) - _msgCoalition(self.Side, msg, 20) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Show Coalition Summary', root, function() - self:ShowCoalitionSummary() - end) - MENU_COALITION_COMMAND:New(self.Side, 'Draw CTLD Zones on Map', root, function() - self:DrawZonesOnMap() - _msgCoalition(self.Side, 'CTLD zones drawn on F10 map.', 8) - end) - MENU_COALITION_COMMAND:New(self.Side, 'Clear CTLD Map Drawings', root, function() - self:ClearMapDrawings() - _msgCoalition(self.Side, 'CTLD map drawings cleared.', 8) - end) - -- Player Help submenu (was below; removed there and added above) - self.AdminMenu = root -end --- #endregion Menus - --- ========================= --- Coalition Summary --- ========================= --- #region Coalition Summary -function CTLD:ShowCoalitionSummary() - -- Crate counts per type (this coalition) - local perType = {} - local total = 0 - for _,meta in pairs(CTLD._crates) do - if meta.side == self.Side then - perType[meta.key] = (perType[meta.key] or 0) + 1 - total = total + 1 - end - end - local lines = {} - table.insert(lines, string.format('CTLD Coalition Summary (%s)', (self.Side==coalition.side.BLUE and 'BLUE') or (self.Side==coalition.side.RED and 'RED') or 'NEUTRAL')) - -- Crate timeout information first (lifetime is in seconds; 0 disables cleanup) - local lifeSec = tonumber(self.Config.CrateLifetime or 0) or 0 - if lifeSec > 0 then - local mins = math.floor((lifeSec + 30) / 60) - table.insert(lines, string.format('Crate Timeout: %d mins (Crates will despawn to prevent clutter)', mins)) - else - table.insert(lines, 'Crate Timeout: Disabled') - end - table.insert(lines, string.format('Active crates: %d', total)) - if next(perType) then - table.insert(lines, 'Crates by type:') - -- stable order: sort keys alphabetically - local keys = {} - for k,_ in pairs(perType) do table.insert(keys, k) end - table.sort(keys) - for _,k in ipairs(keys) do - table.insert(lines, string.format(' %s: %d', k, perType[k])) - end - else - table.insert(lines, 'Crates by type: (none)') - end - - -- Nearby buildable recipes for each active player - table.insert(lines, '\nBuildable near players:') - local players = coalition.getPlayers(self.Side) or {} - if #players == 0 then - table.insert(lines, ' (no active players)') - else - for _,u in ipairs(players) do - local g = u:getGroup() - local gname = g and g:getName() or u:getName() or 'Group' - local pos = u:getPoint() - local here = { x = pos.x, z = pos.z } - local radius = self.Config.BuildRadius or 100 - local nearby = self:GetNearbyCrates(here, radius) - local counts = {} - for _,c in ipairs(nearby) do if c.meta.side == self.Side then counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end end - -- include carried crates if allowed - if self.Config.BuildRequiresGroundCrates ~= true then - local lc = CTLD._loadedCrates[gname] - if lc and lc.byKey then for k,v in pairs(lc.byKey) do counts[k] = (counts[k] or 0) + v end end - end - local insideFOB, _ = self:IsPointInFOBZones(here) - local buildable = {} - -- composite recipes first - for recipeKey,cat in pairs(self.Config.CrateCatalog) do - if type(cat.requires) == 'table' and cat.build then - if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOB) then - local ok = true - for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < qty then ok = false; break end end - if ok then table.insert(buildable, cat.description or recipeKey) end - end - end - end - -- single-key - for key,cat in pairs(self.Config.CrateCatalog) do - if cat and cat.build and (not cat.requires) then - if not (cat.isFOB and self.Config.RestrictFOBToZones and not insideFOB) then - if (counts[key] or 0) >= (cat.required or 1) then table.insert(buildable, cat.description or key) end - end - end - end - if #buildable == 0 then - table.insert(lines, string.format(' %s: none', gname)) - else - -- limit to keep message short - local maxShow = 6 - local shown = {} - for i=1, math.min(#buildable, maxShow) do table.insert(shown, buildable[i]) end - local suffix = (#buildable > maxShow) and string.format(' (+%d more)', #buildable - maxShow) or '' - table.insert(lines, string.format(' %s: %s%s', gname, table.concat(shown, ', '), suffix)) - end - end - end - - -- Quick help card - table.insert(lines, '\nQuick Help:') - table.insert(lines, '- Request crates: CTLD → Request Crate (near Pickup Zones).') - table.insert(lines, '- Build: double-press "Build Here" within '..tostring(self.Config.BuildConfirmWindowSeconds or 10)..'s; cooldown '..tostring(self.Config.BuildCooldownSeconds or 60)..'s per group.') - table.insert(lines, '- Hover Coach: CTLD → Coach & Nav → Enable/Disable; vectors to crates/zones available.') - table.insert(lines, '- Manage crates: Drop One/All from CTLD menu; build consumes nearby crates.') - - _msgCoalition(self.Side, table.concat(lines, '\n'), 25) -end --- #endregion Coalition Summary - --- ========================= --- Crates --- ========================= --- #region Crates --- Note: Menu creation lives in the Menus region; this section handles crate request/spawn/nearby/cleanup only. -function CTLD:RequestCrateForGroup(group, crateKey, opts) - opts = opts or {} - local cat = self.Config.CrateCatalog[crateKey] - if not cat then _msgGroup(group, 'Unknown crate type: '..tostring(crateKey)) return end - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local function _distanceToZone(u, z) - if not (u and z and z.GetPointVec3) then return nil end - local up = u:GetPointVec3() - local zp = z:GetPointVec3() - local dx = (up.x - zp.x) - local dz = (up.z - zp.z) - return math.sqrt(dx*dx + dz*dz) - end - - local defaultZone, defaultDist = self:_nearestActivePickupZone(unit) - local zone = opts.zone or defaultZone - local dist = opts.zoneDist or defaultDist - if zone and (not dist) then - dist = _distanceToZone(unit, zone) - end - - local defs = self:_collectActivePickupDefs() - local hasPickupZones = (#defs > 0) - local maxd = (self.Config.PickupZoneMaxDistance or 10000) - - local zoneName = zone and zone:GetName() or (hasPickupZones and 'nearest zone' or 'NO PICKUP ZONES CONFIGURED') - _eventSend(self, group, nil, 'crate_spawn_requested', { type = tostring(crateKey), zone = zoneName }) - - if not hasPickupZones and self.Config.RequirePickupZoneForCrateRequest then - _eventSend(self, group, nil, 'no_pickup_zones', {}) - return - end - - local spawnPoint - if opts.spawnPoint then - spawnPoint = { x = opts.spawnPoint.x, z = opts.spawnPoint.z } - elseif zone and dist and dist <= maxd then - spawnPoint = self:_computeCrateSpawnPoint(zone, { - minSeparation = opts.minSeparationOverride, - additionalEdgeBuffer = opts.additionalEdgeBuffer, - tries = opts.separationTries, - skipSeparationCheck = opts.skipSeparationCheck, - ignoreCrates = opts.ignoreCrates, - }) - else - if self.Config.RequirePickupZoneForCrateRequest then - local isMetric = _getPlayerIsMetric(unit) - local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric) - local brg = 0 - if zone then - local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() - brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) - end - _eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) - return - else - local p = unit:GetPointVec3() - spawnPoint = { x = p.x + 10, z = p.z + 10 } - end - end - - if not spawnPoint and zone and dist and dist <= maxd then - local centerVec = zone:GetPointVec3() - if centerVec then - spawnPoint = { x = centerVec.x, z = centerVec.z } - end - end - - if not spawnPoint then - _msgGroup(group, 'Crate spawn failed: unable to resolve spawn point.') - return - end - - local zoneNameForStock = zone and zone:GetName() or nil - if self.Config.Inventory and self.Config.Inventory.Enabled then - if not zoneNameForStock then - _msgGroup(group, 'Crate requests must be at a Supply Zone for stock control.') - return - end - CTLD._stockByZone[zoneNameForStock] = CTLD._stockByZone[zoneNameForStock] or {} - local cur = tonumber(CTLD._stockByZone[zoneNameForStock][crateKey] or 0) or 0 - if cur <= 0 then - if self:_TryUseSalvageForCrate(group, crateKey, cat) then - _logVerbose(string.format('[Salvage] Used salvage to spawn %s', crateKey)) - else - _msgGroup(group, string.format('Out of stock at %s for %s', zoneNameForStock, self:_friendlyNameForKey(crateKey))) - return - end - else - CTLD._stockByZone[zoneNameForStock][crateKey] = cur - 1 - end - end - - local cname = string.format('CTLD_CRATE_%s_%d', crateKey, math.random(100000,999999)) - _spawnStaticCargo(self.Side, { x = spawnPoint.x, z = spawnPoint.z }, cat.dcsCargoType or 'uh1h_cargo', cname) - CTLD._crates[cname] = { - key = crateKey, - side = self.Side, - spawnTime = timer.getTime(), - point = { x = spawnPoint.x, z = spawnPoint.z }, - requester = group:GetName(), - } - - _addToSpatialGrid(cname, CTLD._crates[cname], 'crate') - - if zone and (opts.suppressSmoke ~= true) then - local zdef = (self._ZoneDefs and self._ZoneDefs.PickupZones) and self._ZoneDefs.PickupZones[zone:GetName()] or nil - local smokeColor = (zdef and zdef.smoke) or self.Config.PickupZoneSmokeColor - if smokeColor then - local sx, sz = spawnPoint.x, spawnPoint.z - local sy = 0 - if land and land.getHeight then - local ok, h = pcall(land.getHeight, { x = sx, y = sz }) - if ok and type(h) == 'number' then sy = h end - end - _spawnCrateSmoke({ x = sx, y = sy, z = sz }, smokeColor, self.Config.CrateSmoke, cname) - end - end - - do - local unitPos = unit:GetPointVec3() - local from = { x = unitPos.x, z = unitPos.z } - local to = { x = spawnPoint.x, z = spawnPoint.z } - local brg = _bearingDeg(from, to) - local isMetric = _getPlayerIsMetric(unit) - local rngMeters = math.sqrt(((to.x-from.x)^2)+((to.z-from.z)^2)) - local rngV, rngU = _fmtRange(rngMeters, isMetric) - local data = { - id = cname, - type = tostring(crateKey), - brg = brg, - rng = rngV, - rng_u = rngU, - } - _eventSend(self, group, nil, 'crate_spawned', data) - end - - return cname, spawnPoint, zone -end - --- Convenience: for composite recipes (def.requires), request all component crates in one go -function CTLD:RequestRecipeBundleForGroup(group, recipeKey) - local def = self.Config.CrateCatalog[recipeKey] - if not def or type(def.requires) ~= 'table' then - -- Fallback to single crate request - return self:RequestCrateForGroup(group, recipeKey) - end - local unit = group and group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - -- Require proximity to an active pickup zone if inventory is enabled or config requires it - local zone, dist = self:_nearestActivePickupZone(unit) - local maxd = (self.Config.PickupZoneMaxDistance or 10000) - if self.Config.RequirePickupZoneForCrateRequest and (not zone or not dist or dist > maxd) then - local isMetric = _getPlayerIsMetric(unit) - local v, u = _fmtRange(math.max(0, (dist or 0) - maxd), isMetric) - local brg = 0 - if zone then - local up = unit:GetPointVec3(); local zp = zone:GetPointVec3() - brg = _bearingDeg({x=up.x,z=up.z}, {x=zp.x,z=zp.z}) - end - _eventSend(self, group, nil, 'pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) - return - end - if (self.Config.Inventory and self.Config.Inventory.Enabled) then - if not zone then - _msgGroup(group, 'Crate bundle requests must be at a Supply Zone for stock control.') - return - end - local zname = zone:GetName() - local stockTbl = CTLD._stockByZone[zname] or {} - -- Pre-check: ensure we can fulfill at least one bundle (check stock or salvage) - for reqKey, qty in pairs(def.requires) do - local have = tonumber(stockTbl[reqKey] or 0) or 0 - local need = tonumber(qty or 0) or 0 - if need > 0 and have < need then - -- Try salvage for the shortfall - local catEntry = self.Config.CrateCatalog[reqKey] - if not self:_CanUseSalvageForCrate(reqKey, catEntry, need - have) then - _msgGroup(group, string.format('Out of stock at %s for %s bundle: need %d x %s', zname, self:_friendlyNameForKey(recipeKey), need, self:_friendlyNameForKey(reqKey))) - return - end - end - end - end - -- Flatten bundle components into a deterministic order - local ordered = {} - local totalCount = 0 - local keys = {} - for reqKey,_ in pairs(def.requires) do table.insert(keys, reqKey) end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - for _,reqKey in ipairs(keys) do - local qty = tonumber(def.requires[reqKey] or 0) or 0 - for _ = 1, qty do - table.insert(ordered, reqKey) - totalCount = totalCount + 1 - end - end - - if totalCount == 0 then return end - - if totalCount == 1 then - self:RequestCrateForGroup(group, ordered[1], { zone = zone, zoneDist = dist }) - return - end - - local baseSeparation = math.max(0, self.Config.CrateSpawnMinSeparation or 7) - local spacing = self.Config.CrateClusterSpacing or baseSeparation - if spacing < baseSeparation then spacing = baseSeparation end - if spacing < 4 then spacing = 4 end - - local offsets, perRow, rows = self:_buildClusterOffsets(totalCount, spacing) - local clusterPad = math.max(((perRow - 1) * spacing) * 0.5, ((rows - 1) * spacing) * 0.5) - local anchor = zone and self:_computeCrateSpawnPoint(zone, { additionalEdgeBuffer = clusterPad }) or nil - - if not anchor then - for _,reqKey in ipairs(ordered) do - self:RequestCrateForGroup(group, reqKey, { zone = zone, zoneDist = dist }) - end - return - end - - local orient = math.random() * 2 * math.pi - local cosA = math.cos(orient) - local sinA = math.sin(orient) - local zoneCenterVec, zoneRadius = self:_getZoneCenterAndRadius(zone) - local zoneCenter = zoneCenterVec and { x = zoneCenterVec.x, z = zoneCenterVec.z } or nil - local safeRadius = nil - if zoneRadius then - safeRadius = math.max(0, zoneRadius - (self.Config.PickupZoneSpawnEdgeBuffer or 10)) - end - - local spawnPoints = {} - for idx = 1, totalCount do - local off = offsets[idx] or { x = 0, z = 0 } - local rx = off.x * cosA - off.z * sinA - local rz = off.x * sinA + off.z * cosA - local point = { x = anchor.x + rx, z = anchor.z + rz } - - if zoneCenter and safeRadius and safeRadius > 0 then - local dx = point.x - zoneCenter.x - local dz = point.z - zoneCenter.z - local distFromCenter = math.sqrt(dx * dx + dz * dz) - if distFromCenter > safeRadius and distFromCenter > 0 then - local scale = safeRadius / distFromCenter - point.x = zoneCenter.x + dx * scale - point.z = zoneCenter.z + dz * scale - end - end - - for name, meta in pairs(CTLD._crates) do - if meta.side == self.Side then - local dx = point.x - meta.point.x - local dz = point.z - meta.point.z - local distSq = dx * dx + dz * dz - if distSq < (baseSeparation * baseSeparation) then - local distNow = math.sqrt(distSq) - local desired = baseSeparation + 0.5 - if distNow < 0.1 then - point.x = point.x + desired - else - local push = desired - distNow - point.x = point.x + (dx / distNow) * push - point.z = point.z + (dz / distNow) * push - end - if zoneCenter and safeRadius and safeRadius > 0 then - local ndx = point.x - zoneCenter.x - local ndz = point.z - zoneCenter.z - local ndist = math.sqrt(ndx * ndx + ndz * ndz) - if ndist > safeRadius and ndist > 0 then - local scale = safeRadius / ndist - point.x = zoneCenter.x + ndx * scale - point.z = zoneCenter.z + ndz * scale - end - end - end - end - end - - spawnPoints[idx] = point - end - - local smokePlaced = false - for idx, reqKey in ipairs(ordered) do - local point = spawnPoints[idx] - self:RequestCrateForGroup(group, reqKey, { - zone = zone, - zoneDist = dist, - spawnPoint = point, - suppressSmoke = smokePlaced, - }) - smokePlaced = true - end -end - -function CTLD:GetNearbyCrates(point, radius) - local result = {} - for name,meta in pairs(CTLD._crates) do - local dx = (meta.point.x - point.x) - local dz = (meta.point.z - point.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= radius then - table.insert(result, { name = name, meta = meta }) - end - end - return result -end - -function CTLD:CleanupCrates() - local now = timer.getTime() - local life = self.Config.CrateLifetime - for name,meta in pairs(CTLD._crates) do - if now - (meta.spawnTime or now) > life then - local obj = StaticObject.getByName(name) - if obj then obj:destroy() end - _cleanupCrateSmoke(name) -- Clean up smoke refresh schedule - _removeFromSpatialGrid(name, meta.point, 'crate') -- Remove from spatial index - CTLD._crates[name] = nil - _logDebug('Cleaned up crate '..name) - -- Notify requester group if still around; else coalition - local gname = meta.requester - local group = gname and GROUP:FindByName(gname) or nil - if group and group:IsAlive() then - _eventSend(self, group, nil, 'crate_expired', { id = name }) - else - _eventSend(self, nil, self.Side, 'crate_expired', { id = name }) - end - end - end -end - -function CTLD:CleanupDeployedTroops() - -- Remove any deployed troop groups that are dead or no longer exist - for troopGroupName, troopMeta in pairs(CTLD._deployedTroops) do - if troopMeta.side == self.Side then - local troopGroup = GROUP:FindByName(troopGroupName) - if not troopGroup or not troopGroup:IsAlive() then - -- Remove from spatial grid if point is available - if troopMeta.point then - _removeFromSpatialGrid(troopGroupName, troopMeta.point, 'troops') - end - CTLD._deployedTroops[troopGroupName] = nil - _logDebug('Cleaned up deployed troop group: '..troopGroupName) - end - end - end -end - --- Comprehensive state pruning to prevent memory leaks -function CTLD:PruneOrphanedState() - local pruned = 0 - - -- 1. Prune spatial grid entries for non-existent crates/troops - for gridKey, cell in pairs(CTLD._spatialGrid) do - -- Check crates in this cell - for crateName, _ in pairs(cell.crates) do - if not CTLD._crates[crateName] then - cell.crates[crateName] = nil - pruned = pruned + 1 - end - end - -- Check troops in this cell - for troopName, _ in pairs(cell.troops) do - if not CTLD._deployedTroops[troopName] then - cell.troops[troopName] = nil - pruned = pruned + 1 - end - end - -- Remove empty cells - if not next(cell.crates) and not next(cell.troops) then - CTLD._spatialGrid[gridKey] = nil - end - end - - -- 2. Prune JTAC registry for non-existent groups - if self._jtacRegistry then - for gname, _ in pairs(self._jtacRegistry) do - local g = Group.getByName(gname) - if not g or not g:isExist() then - self:_cleanupJTACEntry(gname) - pruned = pruned + 1 - end - end - end - - -- 3. Prune hover/coach state for non-existent units - local function pruneUnitState(stateTbl, label) - if not stateTbl then return end - for unitName, _ in pairs(stateTbl) do - local u = Unit.getByName(unitName) - if not u or not u:isExist() or u:getLife() <= 0 then - stateTbl[unitName] = nil - pruned = pruned + 1 - end - end - end - - pruneUnitState(CTLD._hoverState, 'hover') - pruneUnitState(CTLD._coachState, 'coach') - pruneUnitState(CTLD._groundLoadState, 'groundLoad') - pruneUnitState(CTLD._unitLast, 'unitLast') - - -- 4. Prune group-level state for non-existent groups - local function pruneGroupState(stateTbl, label) - if not stateTbl then return end - for gname, _ in pairs(stateTbl) do - local g = GROUP:FindByName(gname) - if not g or not g:IsAlive() then - stateTbl[gname] = nil - pruned = pruned + 1 - end - end - end - - pruneGroupState(CTLD._troopsLoaded, 'troopsLoaded') - pruneGroupState(CTLD._loadedCrates, 'loadedCrates') - pruneGroupState(CTLD._loadedTroopTypes, 'loadedTroopTypes') - pruneGroupState(CTLD._buildConfirm, 'buildConfirm') - pruneGroupState(CTLD._medevacUnloadStates, 'medevacUnload') - pruneGroupState(CTLD._medevacLoadStates, 'medevacLoad') - pruneGroupState(CTLD._medevacEnrouteStates, 'medevacEnroute') - - -- 5. Prune _inStockMenus for non-existent groups - if CTLD._inStockMenus then - for gname, _ in pairs(CTLD._inStockMenus) do - local g = GROUP:FindByName(gname) - if not g or not g:IsAlive() then - CTLD._inStockMenus[gname] = nil - pruned = pruned + 1 - end - end - end - - if pruned > 0 then - _logVerbose(string.format('[StateMaint] Pruned %d orphaned state entries', pruned)) - end -end --- #endregion Crates - --- ========================= --- Build logic --- ========================= --- #region Build logic -function CTLD:BuildAtGroup(group, opts) - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - if self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC trace: entering BuildAtGroup for group=%s', tostring(group:GetName()))) - end - -- Build cooldown/confirmation guardrails - local now = timer.getTime() - local gname = group:GetName() - if self.Config.BuildCooldownEnabled then - local last = CTLD._buildCooldown[gname] - if last and (now - last) < (self.Config.BuildCooldownSeconds or 60) then - local rem = math.max(0, math.ceil((self.Config.BuildCooldownSeconds or 60) - (now - last))) - _msgGroup(group, string.format('Build on cooldown. Try again in %ds.', rem)) - return - end - end - if self.Config.BuildConfirmEnabled then - local first = CTLD._buildConfirm[gname] - local win = self.Config.BuildConfirmWindowSeconds or 10 - if not first or (now - first) > win then - CTLD._buildConfirm[gname] = now - _msgGroup(group, string.format('Confirm build: select "Build Here" again within %ds to proceed.', win)) - return - else - -- within window; proceed and clear pending - CTLD._buildConfirm[gname] = nil - end - end - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - -- Compute a safe spawn point offset forward from the aircraft to prevent rotor/ground collisions with spawned units - local hdgRad, hdgDeg = _headingRadDeg(unit) - local buildOffset = math.max(0, tonumber(self.Config.BuildSpawnOffset or 0) or 0) - local spawnAt = (buildOffset > 0) and { x = here.x + math.sin(hdgRad) * buildOffset, z = here.z + math.cos(hdgRad) * buildOffset } or { x = here.x, z = here.z } - local radius = self.Config.BuildRadius - local nearby = self:GetNearbyCrates(here, radius) - -- filter crates to coalition side for this CTLD instance - local filtered = {} - for _,c in ipairs(nearby) do - if c.meta.side == self.Side then table.insert(filtered, c) end - end - nearby = filtered - if #nearby == 0 then - _eventSend(self, group, nil, 'build_insufficient_crates', { build = 'asset' }) - -- Nudge players to use Recipe Info - _msgGroup(group, 'Tip: Use CTLD → Recipe Info to see exact crate requirements for each build.') - return - end - - -- Count by key - local counts = {} - for _,c in ipairs(nearby) do - counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 - end - - -- Include loaded crates carried by this group - local carried = CTLD._loadedCrates[gname] - if self.Config.BuildRequiresGroundCrates ~= true then - if carried and carried.byKey then - for k,v in pairs(carried.byKey) do - counts[k] = (counts[k] or 0) + v - end - end - end - - -- Helper to consume crates of a given key/qty - local function consumeCrates(key, qty) - local removed = 0 - -- Optionally consume from carried crates - if self.Config.BuildRequiresGroundCrates ~= true then - if carried and carried.byKey and (carried.byKey[key] or 0) > 0 then - local take = math.min(qty, carried.byKey[key]) - carried.byKey[key] = carried.byKey[key] - take - if carried.byKey[key] <= 0 then carried.byKey[key] = nil end - carried.total = math.max(0, (carried.total or 0) - take) - removed = removed + take - if take > 0 then ctld:_scheduleLoadedCrateMenuRefresh(group) end - end - end - for _,c in ipairs(nearby) do - if removed >= qty then break end - if c.meta.key == key then - local obj = StaticObject.getByName(c.name) - if obj then obj:destroy() end - _cleanupCrateSmoke(c.name) -- Clean up smoke refresh schedule - CTLD._crates[c.name] = nil - removed = removed + 1 - end - end - end - - local insideFOBZone, fz = self:IsPointInFOBZones(here) - local fobBlocked = false - - -- Build All mode: when BuildCooldownSeconds = 0, loop and build all available assets - local buildAllMode = (tonumber(self.Config.BuildCooldownSeconds) or 0) == 0 - local builtCount = 0 - local buildLoop = true - - -- Helper to calculate dispersed spawn position for Build All mode - local function getDispersedSpawnPoint(basePoint, isFOB) - -- FOBs always spawn at the designated point (no dispersion) - if isFOB then - return { x = basePoint.x, z = basePoint.z } - end - - -- In Build All mode with dispersion enabled, randomize spawn position - if buildAllMode and (self.Config.BuildDispersionRadius or 0) > 0 then - local dispRadius = self.Config.BuildDispersionRadius - -- Random angle (0-360 degrees) - local angle = math.random() * 2 * math.pi - -- Random distance (0 to dispRadius, with bias toward outer ring for better spread) - local distance = math.sqrt(math.random()) * dispRadius - return { - x = basePoint.x + math.cos(angle) * distance, - z = basePoint.z + math.sin(angle) * distance - } - end - - -- Default: use base point - return { x = basePoint.x, z = basePoint.z } - end - - while buildLoop do - buildLoop = false -- Only loop if we successfully build something in Build All mode - - -- Try composite recipes first (requires is a map of key->qty) - for recipeKey,cat in pairs(self.Config.CrateCatalog) do - if type(cat.requires) == 'table' and cat.build then - if cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone then - fobBlocked = true - else - -- Build caps disabled: rely solely on inventory/catalog control - local ok = true - for reqKey,qty in pairs(cat.requires) do - if (counts[reqKey] or 0) < qty then ok = false; break end - end - if ok then - -- Calculate spawn position (with dispersion for non-FOB builds in Build All mode) - local actualSpawn = getDispersedSpawnPoint(spawnAt, cat.isFOB) - local gdata = cat.build({ x = actualSpawn.x, z = actualSpawn.z }, hdgDeg, cat.side or self.Side) - _eventSend(self, group, nil, 'build_started', { build = cat.description or recipeKey }) - local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) - if g then - if self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC trace: composite build spawned group=%s recipe=%s', tostring(g:getName()), tostring(recipeKey))) - end - -- Register JTAC if applicable (composite recipe) - self:_maybeRegisterJTAC(recipeKey, cat, g) - for reqKey,qty in pairs(cat.requires) do - consumeCrates(reqKey, qty) - counts[reqKey] = math.max(0, (counts[reqKey] or 0) - qty) - end - builtCount = builtCount + 1 - -- No site cap counters when caps are disabled - _eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or recipeKey, player = _playerNameFromGroup(group) }) - -- If this was a FOB, register a new pickup zone with reduced stock - if cat.isFOB then - pcall(function() - self:_CreateFOBPickupZone({ x = actualSpawn.x, z = actualSpawn.z }, cat, hdg) - end) - end - -- Assign optional behavior for built vehicles/groups - local behavior = opts and opts.behavior or nil - if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then - local t = self:_assignAttackBehavior(g:getName(), actualSpawn, true) - local isMetric = _getPlayerIsMetric(group:GetUnit(1)) - if t and t.kind == 'base' then - local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) - elseif t and t.kind == 'enemy' then - local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) - else - local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) - _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) - end - end - if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end - if buildAllMode then - buildLoop = true -- Continue building in Build All mode - break -- Break from recipe loop to restart search - else - return -- Single build mode - return after first build - end - else - _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) - return - end - end - -- continue_composite (Lua 5.1 compatible: no labels) - end - end - end - - -- Then single-key recipes (only if we didn't build a composite) - if not buildLoop or not buildAllMode then - for key,count in pairs(counts) do - local cat = self.Config.CrateCatalog[key] - if cat and cat.build and (not cat.requires) and count >= (cat.required or 1) then - if cat.isFOB and self.Config.RestrictFOBToZones and not insideFOBZone then - fobBlocked = true - else - -- Build caps disabled: rely solely on inventory/catalog control - -- Calculate spawn position (with dispersion for non-FOB builds in Build All mode) - local actualSpawn = getDispersedSpawnPoint(spawnAt, cat.isFOB) - local gdata = cat.build({ x = actualSpawn.x, z = actualSpawn.z }, hdgDeg, cat.side or self.Side) - _eventSend(self, group, nil, 'build_started', { build = cat.description or key }) - local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) - if g then - if self.Config.JTAC and self.Config.JTAC.Verbose then - _logInfo(string.format('JTAC trace: single build spawned group=%s key=%s', tostring(g:getName()), tostring(key))) - end - -- Register JTAC if applicable (single-unit recipe) - self:_maybeRegisterJTAC(key, cat, g) - consumeCrates(key, cat.required or 1) - counts[key] = math.max(0, (counts[key] or 0) - (cat.required or 1)) - builtCount = builtCount + 1 - -- No single-unit cap counters when caps are disabled - _eventSend(self, nil, self.Side, 'build_success_coalition', { build = cat.description or key, player = _playerNameFromGroup(group) }) - -- Assign optional behavior for built vehicles/groups - local behavior = opts and opts.behavior or nil - if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then - local t = self:_assignAttackBehavior(g:getName(), actualSpawn, true) - local isMetric = _getPlayerIsMetric(group:GetUnit(1)) - if t and t.kind == 'base' then - local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) - elseif t and t.kind == 'enemy' then - local brg = _bearingDeg({ x = actualSpawn.x, z = actualSpawn.z }, { x = t.point.x, z = t.point.z }) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = g:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) - else - local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.VehicleSearchRadius) or 5000, isMetric) - _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = g:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) - end - end - if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end - if buildAllMode then - buildLoop = true -- Continue building in Build All mode - break -- Break from counts loop to restart search - else - return -- Single build mode - return after first build - end - else - _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }) - return - end - end - end - -- continue_single (Lua 5.1 compatible: no labels) - end - end - end - - -- If we built anything in Build All mode, we're done successfully - if builtCount > 0 then - if buildAllMode then - _msgGroup(group, string.format('Build All complete: deployed %d asset(s).', builtCount)) - end - return - end - - if fobBlocked then - _eventSend(self, group, nil, 'fob_restricted', {}) - return - end - - -- Helpful hint if building requires ground crates and player is carrying crates - if self.Config.BuildRequiresGroundCrates == true then - local carried = CTLD._loadedCrates[gname] - if carried and (carried.total or 0) > 0 then - _eventSend(self, group, nil, 'build_requires_ground', { total = carried.total }) - return - end - end - _eventSend(self, group, nil, 'build_insufficient_crates', { build = 'asset' }) - -- Provide a short breakdown of most likely recipes and what is missing - local suggestions = {} - local function pushSuggestion(name, missingStr, haveParts, totalParts) - table.insert(suggestions, { name = name, miss = missingStr, have = haveParts, total = totalParts }) - end - -- consider composite recipes with at least one matching component nearby - for rkey,cat in pairs(self.Config.CrateCatalog) do - if type(cat.requires) == 'table' then - local have, total, missingList = 0, 0, {} - for reqKey,qty in pairs(cat.requires) do - total = total + (qty or 0) - local haveHere = math.min(qty or 0, counts[reqKey] or 0) - have = have + haveHere - local need = math.max(0, (qty or 0) - (counts[reqKey] or 0)) - if need > 0 then - local fname = self:_friendlyNameForKey(reqKey) - table.insert(missingList, string.format('%dx %s', need, fname)) - end - end - if have > 0 and have < total then - local name = cat.description or cat.menu or rkey - pushSuggestion(name, table.concat(missingList, ', '), have, total) - end - else - -- single-key recipe: if some crates present but not enough - local need = (cat and (cat.required or 1)) or 1 - local have = counts[rkey] or 0 - if have > 0 and have < need then - local name = cat.description or cat.menu or rkey - pushSuggestion(name, string.format('%d more crate(s) of %s', need - have, self:_friendlyNameForKey(rkey)), have, need) - end - end - end - table.sort(suggestions, function(a,b) - local ra = (a.total > 0) and (a.have / a.total) or 0 - local rb = (b.total > 0) and (b.have / b.total) or 0 - if ra == rb then return (a.total - a.have) < (b.total - b.have) end - return ra > rb - end) - if #suggestions > 0 then - local maxShow = math.min(2, #suggestions) - for i=1,maxShow do - local s = suggestions[i] - _msgGroup(group, string.format('Missing for %s: %s', s.name, s.miss)) - end - else - _msgGroup(group, 'No matching recipe found with nearby crates. Check Recipe Info for requirements.') - end -end --- #endregion Build logic - --- ========================= --- Loaded crate management --- ========================= --- #region Loaded crate management - --- Update DCS internal cargo weight for a group -function CTLD:_updateCargoWeight(group) - _updateCargoWeight(group) -end - -function CTLD:_addLoadedCrate(group, crateKey) - local gname = group:GetName() - CTLD._loadedCrates[gname] = CTLD._loadedCrates[gname] or { total = 0, totalWeightKg = 0, byKey = {} } - local lc = CTLD._loadedCrates[gname] - lc.total = lc.total + 1 - lc.byKey[crateKey] = (lc.byKey[crateKey] or 0) + 1 - - -- Add weight from catalog - local cat = self.Config.CrateCatalog[crateKey] - local crateWeight = (cat and cat.weightKg) or 0 - lc.totalWeightKg = (lc.totalWeightKg or 0) + crateWeight - - -- Update DCS internal cargo weight - self:_updateCargoWeight(group) - - -- Refresh drop-by-type menu after loading - self:_scheduleLoadedCrateMenuRefresh(group) -end - -function CTLD:_clearLoadedCrateMenuCommands(gname) - self._loadedCrateMenus = self._loadedCrateMenus or {} - local state = self._loadedCrateMenus[gname] - if not state or not state.commands then return end - for _,cmd in ipairs(state.commands) do - if cmd and cmd.Remove then - pcall(function() cmd:Remove() end) - end - end - state.commands = {} -end - -function CTLD:_BuildOrRefreshLoadedCrateMenu(group, parentMenu) - if not group then return end - self._loadedCrateMenus = self._loadedCrateMenus or {} - local gname = group:GetName() - local state = self._loadedCrateMenus[gname] or { commands = {} } - state.parent = parentMenu - state.groupName = gname - self._loadedCrateMenus[gname] = state - - self:_clearLoadedCrateMenuCommands(gname) - - local carried = CTLD._loadedCrates[gname] - local byKey = (carried and carried.byKey) or {} - local keys = {} - for key,count in pairs(byKey) do - if count and count > 0 then table.insert(keys, key) end - end - - local ctld = self - if #keys == 0 then - local cmd = MENU_GROUP_COMMAND:New(group, 'No crates onboard', parentMenu, function() - _msgGroup(group, 'No crates loaded to drop individually.') - end) - table.insert(state.commands, cmd) - return - end - - table.sort(keys, function(a, b) - local fa = ctld:_friendlyNameForKey(a) or a - local fb = ctld:_friendlyNameForKey(b) or b - if fa == fb then return a < b end - return fa < fb - end) - - for _,key in ipairs(keys) do - local count = byKey[key] or 0 - local friendly = ctld:_friendlyNameForKey(key) or key - local title = string.format('Drop %s (%d)', friendly, count) - local cmd = MENU_GROUP_COMMAND:New(group, title, parentMenu, function() - ctld:DropLoadedCrates(group, 1, key) - end) - table.insert(state.commands, cmd) - end -end - -function CTLD:_scheduleLoadedCrateMenuRefresh(group) - if not group then return end - self._loadedCrateMenus = self._loadedCrateMenus or {} - local gname = group:GetName() - local state = self._loadedCrateMenus[gname] - if not state or not state.parent then return end - local ctld = self - local id = timer.scheduleFunction(function() - local g = GROUP:FindByName(gname) - if not g then return end - ctld:_BuildOrRefreshLoadedCrateMenu(g, state.parent) - return - end, {}, timer.getTime() + 0.1) - _trackOneShotTimer(id) -end - -function CTLD:DropLoadedCrates(group, howMany, crateKey) - local gname = group:GetName() - local lc = CTLD._loadedCrates[gname] - if not lc or (lc.total or 0) == 0 then _eventSend(self, group, nil, 'no_loaded_crates', {}) return end - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - -- Restrict dropping crates inside Pickup Zones if configured - if self.Config.ForbidDropsInsidePickupZones then - local activeOnly = (self.Config.ForbidChecksActivePickupOnly ~= false) - local inside = false - local ok, err = pcall(function() - inside = select(1, self:_isUnitInsidePickupZone(unit, activeOnly)) - end) - if ok and inside then - _eventSend(self, group, nil, 'drop_forbidden_in_pickup', {}) - return - end - end - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - -- Offset drop point forward of the aircraft to avoid rotor/airframe damage - local hdgRad, _ = _headingRadDeg(unit) - local fwd = math.max(0, tonumber(self.Config.DropCrateForwardOffset or 20) or 0) - local dropPt = (fwd > 0) and { x = here.x + math.sin(hdgRad) * fwd, z = here.z + math.cos(hdgRad) * fwd } or { x = here.x, z = here.z } - local initialTotal = lc.total or 0 - local requested = (howMany and howMany > 0) and howMany or initialTotal - - local dropPlan = {} - if crateKey then - local available = lc.byKey[crateKey] or 0 - if available <= 0 then - local friendly = self:_friendlyNameForKey(crateKey) or crateKey - _msgGroup(group, string.format('No %s crates loaded.', friendly)) - return - end - local qty = math.min(requested, available) - table.insert(dropPlan, { key = crateKey, count = qty }) - else - local keys = {} - for key,_ in pairs(lc.byKey) do table.insert(keys, key) end - table.sort(keys, function(a, b) - local fa = self:_friendlyNameForKey(a) or a - local fb = self:_friendlyNameForKey(b) or b - if fa == fb then return a < b end - return fa < fb - end) - local remaining = math.min(requested, initialTotal) - for _,key in ipairs(keys) do - if remaining <= 0 then break end - local available = lc.byKey[key] or 0 - if available > 0 then - local qty = math.min(available, remaining) - table.insert(dropPlan, { key = key, count = qty }) - remaining = remaining - qty - end - end - end - - local totalToDrop = 0 - for _,entry in ipairs(dropPlan) do totalToDrop = totalToDrop + (entry.count or 0) end - if totalToDrop <= 0 then - _msgGroup(group, 'No valid crates selected to drop.') - return - end - - _eventSend(self, group, nil, 'drop_initiated', { count = totalToDrop, key = crateKey }) - -- Warn about crate timeout when dropping - local lifeSec = tonumber(self.Config.CrateLifetime or 0) or 0 - if lifeSec > 0 then - local mins = math.floor((lifeSec + 30) / 60) - _msgGroup(group, string.format('Note: Crates will despawn after %d mins to prevent clutter.', mins)) - end - -- Drop following the prepared plan - for _,entry in ipairs(dropPlan) do - local k = entry.key - local dropNow = entry.count or 0 - if dropNow > 0 then - local cat = self.Config.CrateCatalog[k] - local crateWeight = (cat and cat.weightKg) or 0 - for i=1,dropNow do - local cname = string.format('CTLD_CRATE_%s_%d', k, math.random(100000,999999)) - _spawnStaticCargo(self.Side, dropPt, (cat and cat.dcsCargoType) or 'uh1h_cargo', cname) - CTLD._crates[cname] = { key = k, side = self.Side, spawnTime = timer.getTime(), point = { x = dropPt.x, z = dropPt.z } } - -- Add to spatial index - _addToSpatialGrid(cname, CTLD._crates[cname], 'crate') - lc.byKey[k] = lc.byKey[k] - 1 - if lc.byKey[k] <= 0 then lc.byKey[k] = nil end - lc.total = lc.total - 1 - lc.totalWeightKg = (lc.totalWeightKg or 0) - crateWeight - end - end - end - local actualDropped = initialTotal - (lc.total or 0) - _eventSend(self, group, nil, 'dropped_crates', { count = actualDropped, key = crateKey }) - - -- Update DCS internal cargo weight after dropping - self:_updateCargoWeight(group) - - -- Refresh drop-by-type menu after dropping - self:_scheduleLoadedCrateMenuRefresh(group) - - -- Reiterate timeout after drop completes (players may miss the initial warning) - if lifeSec > 0 then - local mins = math.floor((lifeSec + 30) / 60) - _msgGroup(group, string.format('Reminder: Dropped crates will despawn after %d mins to prevent clutter.', mins)) - end -end - --- Show inventory at the nearest pickup zone/FOB -function CTLD:ShowNearestZoneInventory(group) - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - -- Find nearest active pickup zone - local zone, dist = self:_nearestActivePickupZone(unit) - - if not zone then - _msgGroup(group, 'No active pickup zones found nearby. Move closer to a supply zone.') - return - end - - local zoneName = zone:GetName() - local isMetric = _getPlayerIsMetric(unit) - local rngV, rngU = _fmtRange(dist, isMetric) - - -- Get inventory for this zone - local stockTbl = CTLD._stockByZone[zoneName] or {} - - -- Build the inventory display - local lines = {} - table.insert(lines, string.format('Inventory at %s', zoneName)) - table.insert(lines, string.format('Distance: %.1f %s', rngV, rngU)) - table.insert(lines, '') - - -- Check if inventory system is enabled - local invEnabled = self.Config.Inventory and self.Config.Inventory.Enabled ~= false - if not invEnabled then - table.insert(lines, 'Inventory tracking is disabled - all items available.') - _msgGroup(group, table.concat(lines, '\n'), 20) - return - end - - -- Count total items and organize by category - local totalItems = 0 - local byCategory = {} - - for key, count in pairs(stockTbl) do - if count > 0 then - local def = self.Config.CrateCatalog[key] - if def and ((not def.side) or def.side == self.Side) then - local cat = (def.menuCategory or 'Other') - byCategory[cat] = byCategory[cat] or {} - table.insert(byCategory[cat], { - key = key, - name = def.menu or def.description or key, - count = count, - isRecipe = (type(def.requires) == 'table') - }) - totalItems = totalItems + count - end - end - end - - if totalItems == 0 then - table.insert(lines, 'No items in stock at this location.') - table.insert(lines, 'Request resupply or move to another zone.') - else - table.insert(lines, string.format('Total items in stock: %d', totalItems)) - table.insert(lines, '') - - -- Sort categories for consistent display - local categories = {} - for cat, _ in pairs(byCategory) do - table.insert(categories, cat) - end - table.sort(categories) - - -- Display items by category - for _, cat in ipairs(categories) do - table.insert(lines, string.format('-- %s --', cat)) - local items = byCategory[cat] - -- Sort items by name - table.sort(items, function(a, b) return a.name < b.name end) - for _, item in ipairs(items) do - if item.isRecipe then - -- For recipes, calculate available bundles - local def = self.Config.CrateCatalog[item.key] - local bundles = math.huge - if def and def.requires then - for reqKey, qty in pairs(def.requires) do - local have = tonumber(stockTbl[reqKey] or 0) or 0 - local need = tonumber(qty or 0) or 0 - if need > 0 then - bundles = math.min(bundles, math.floor(have / need)) - end - end - end - if bundles == math.huge then bundles = 0 end - table.insert(lines, string.format(' %s: %d bundle%s', item.name, bundles, (bundles == 1 and '' or 's'))) - else - table.insert(lines, string.format(' %s: %d', item.name, item.count)) - end - end - table.insert(lines, '') - end - end - - -- Display the inventory - _msgGroup(group, table.concat(lines, '\n'), 30) -end - --- #endregion Loaded crate management - --- ========================= --- Hover pickup scanner --- ========================= --- #region Hover pickup scanner -function CTLD:ScanHoverPickup() - local coachCfg = CTLD.HoverCoachConfig or { enabled = false } - if not coachCfg.enabled then return end - - -- iterate all groups that have menus (active transports) - for gname,_ in pairs(self.MenusByGroup or {}) do - local group = GROUP:FindByName(gname) - if group and group:IsAlive() then - local unit = group:GetUnit(1) - if unit and unit:IsAlive() then - -- Allowed type check - local typ = _getUnitType(unit) - if _isIn(self.Config.AllowedAircraft, typ) then - local uname = unit:GetName() - local now = timer.getTime() - local p3 = unit:GetPointVec3() - local ground = land and land.getHeight and land.getHeight({ x = p3.x, y = p3.z }) or 0 - local agl = math.max(0, p3.y - ground) - - -- Skip hover coaching if on the ground (let ground auto-load handle it) - local groundCfg = CTLD.GroundAutoLoadConfig or {} - local groundContactAGL = groundCfg.GroundContactAGL or 3.5 - if agl <= groundContactAGL then - -- On ground, clear hover state and skip hover logic - CTLD._hoverState[uname] = nil - -- Also, when firmly landed, suppress any completed ground-load state - -- so returning to this spot without crates won't keep retriggering. - if CTLD._groundLoadState and CTLD._groundLoadState[uname] and CTLD._groundLoadState[uname].completed then - CTLD._groundLoadState[uname] = nil - end - else - -- speeds (ground/vertical) - local last = CTLD._unitLast[uname] - local gs, vs = 0, 0 - if last and (now > (last.t or 0)) then - local dt = now - last.t - if dt > 0 then - local dx = (p3.x - last.x) - local dz = (p3.z - last.z) - gs = math.sqrt(dx*dx + dz*dz) / dt - if last.agl then vs = (agl - last.agl) / dt end - end - end - CTLD._unitLast[uname] = { x = p3.x, z = p3.z, t = now, agl = agl } - - -- Use spatial indexing to find nearby crates/troops efficiently - local maxd = coachCfg.autoPickupDistance or 25 - local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxd) - - local friendlyCrateCount = 0 - local friendlyTroopCount = 0 - local bestName, bestMeta, bestd - local bestType = 'crate' - - -- Search nearby crates from spatial grid - for name, meta in pairs(nearby.crates or {}) do - if meta.side == self.Side then - friendlyCrateCount = friendlyCrateCount + 1 - local dx = (meta.point.x - p3.x) - local dz = (meta.point.z - p3.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= maxd and ((not bestd) or d < bestd) then - bestName, bestMeta, bestd = name, meta, d - bestType = 'crate' - end - end - end - - -- Search nearby deployed troops from spatial grid - for troopGroupName, troopMeta in pairs(nearby.troops or {}) do - if troopMeta.side == self.Side then - friendlyTroopCount = friendlyTroopCount + 1 - local troopGroup = GROUP:FindByName(troopGroupName) - if troopGroup and troopGroup:IsAlive() then - local troopPos = troopGroup:GetCoordinate() - if troopPos then - local tp = troopPos:GetVec3() - local dx = (tp.x - p3.x) - local dz = (tp.z - p3.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= maxd and ((not bestd) or d < bestd) then - bestName, bestMeta, bestd = troopGroupName, troopMeta, d - bestType = 'troops' - end - end - else - -- Group doesn't exist or is dead, remove from tracking - _removeFromSpatialGrid(troopGroupName, troopMeta.point, 'troops') - CTLD._deployedTroops[troopGroupName] = nil - end - end - end - - if CTLD.Config and CTLD.Config.DebugHoverCrates and (friendlyCrateCount > 0 or friendlyTroopCount > 0) then - local debugLabel = bestName or 'none' - local debugType = bestType - local debugDist = bestd - if not bestName and friendlyCrateCount > 0 then - debugLabel = 'pending' - debugType = 'crate' - debugDist = math.huge - end - _debugCrateSight('HoverDebug', { - unit = uname, - now = now, - interval = CTLD.Config.DebugHoverCratesInterval, - step = CTLD.Config.DebugHoverCratesStep, - name = debugLabel, - distance = debugDist, - count = friendlyCrateCount, - troops = friendlyTroopCount, - radius = maxd, - typeHint = debugType, - note = string.format('coachGS<=%.1f', coachCfg.thresholds.captureGS or (4/3.6)), - }) - end - - local coachEnabled = coachCfg.enabled - if CTLD._coachOverride and CTLD._coachOverride[gname] ~= nil then - coachEnabled = CTLD._coachOverride[gname] - end - - -- If coach is on, provide phased guidance - if coachEnabled and bestName and bestMeta then - local thresholds = coachCfg.thresholds or {} - local isMetric = _getPlayerIsMetric(unit) - - -- Arrival phase - if bestd <= (thresholds.arrivalDist or 1000) then - _coachSend(self, group, uname, 'coach_arrival', {}, false) - end - - -- Close-in guidance - if bestd <= (thresholds.closeDist or 100) then - _coachSend(self, group, uname, 'coach_close', {}, false) - end - - -- Precision phase - if bestd <= (thresholds.precisionDist or 30) then - local hdg, _ = _headingRadDeg(unit) - local dx = (bestMeta.point.x - p3.x) - local dz = (bestMeta.point.z - p3.z) - local right, fwd = _projectToBodyFrame(dx, dz, hdg) - - -- Horizontal hint formatting - local function hintDir(val, posWord, negWord, toUnits) - local mag = math.abs(val) - local v, u = _fmtDistance(mag, isMetric) - if mag < 0.5 then return nil end - return string.format("%s %d %s", (val >= 0 and posWord or negWord), v, u) - end - local h = {} - local rHint = hintDir(right, 'Right', 'Left') - local fHint = hintDir(fwd, 'Forward', 'Back') - if rHint then table.insert(h, rHint) end - if fHint then table.insert(h, fHint) end - - -- Vertical hint against AGL window - local vHint - local aglMin = thresholds.aglMin or 5 - local aglMax = thresholds.aglMax or 20 - if agl < aglMin then - local dv, du = _fmtAGL(aglMin - agl, isMetric) - vHint = string.format("Up %d %s", dv, du) - elseif agl > aglMax then - local dv, du = _fmtAGL(agl - aglMax, isMetric) - vHint = string.format("Down %d %s", dv, du) - end - if vHint then table.insert(h, vHint) end - - local hints = table.concat(h, ", ") - local gsV, gsU = _fmtSpeed(gs, isMetric) - local data = { hints = (hints ~= '' and (hints..'.') or ''), gs = gsV, gs_u = gsU } - - _coachSend(self, group, uname, 'coach_hint', data, true) - - -- Error prompts (dominant one) - local maxGS = thresholds.maxGS or (8/3.6) - local aglMinT = aglMin - local aglMaxT = aglMax - if gs > maxGS then - local v, u = _fmtSpeed(gs, isMetric) - _coachSend(self, group, uname, 'coach_too_fast', { gs = v, gs_u = u }, false) - elseif agl > aglMaxT then - local v, u = _fmtAGL(agl, isMetric) - _coachSend(self, group, uname, 'coach_too_high', { agl = v, agl_u = u }, false) - elseif agl < aglMinT then - local v, u = _fmtAGL(agl, isMetric) - _coachSend(self, group, uname, 'coach_too_low', { agl = v, agl_u = u }, false) - end - end - end - - -- Auto-load logic using capture thresholds - local capGS = coachCfg.thresholds.captureGS or (4/3.6) - local aglMin = coachCfg.thresholds.aglMin or 5 - local aglMax = coachCfg.thresholds.aglMax or 20 - local speedOK = gs <= capGS - local heightOK = (agl >= aglMin and agl <= aglMax) - - if bestName and bestMeta and speedOK and heightOK then - local withinRadius = bestd <= (coachCfg.thresholds.captureHoriz or 2) - - if withinRadius then - local carried = CTLD._loadedCrates[gname] - local total = carried and carried.total or 0 - local currentWeight = carried and carried.totalWeightKg or 0 - - -- Get aircraft-specific capacity instead of global setting - local capacity = _getAircraftCapacity(unit) - local maxCrates = capacity.maxCrates - local maxTroops = capacity.maxTroops - local maxWeight = capacity.maxWeightKg or 0 - - -- Calculate weight and check capacity based on type - local itemWeight = 0 - local countOK = false - local weightOK = false - - if bestType == 'crate' then - -- Picking up a crate - itemWeight = (bestMeta and self.Config.CrateCatalog[bestMeta.key] and self.Config.CrateCatalog[bestMeta.key].weightKg) or 0 - local wouldBeWeight = currentWeight + itemWeight - countOK = (total < maxCrates) - weightOK = (maxWeight <= 0) or (wouldBeWeight <= maxWeight) - elseif bestType == 'troops' then - -- Picking up troops - check if we can ADD them to existing load - itemWeight = bestMeta.weightKg or 0 - local wouldBeWeight = currentWeight + itemWeight - local troopCount = bestMeta.count or 0 - - -- Check if we already have troops loaded - if so, check if we can add more - local currentTroops = CTLD._troopsLoaded[gname] - local currentTroopCount = currentTroops and currentTroops.count or 0 - local totalTroopCount = currentTroopCount + troopCount - - -- Check total capacity (allow mixing different troop types) - countOK = (totalTroopCount <= maxTroops) - weightOK = (maxWeight <= 0) or (wouldBeWeight <= maxWeight) - - -- Provide feedback if capacity exceeded - if not countOK then - local hs = CTLD._hoverState[uname] - if not hs or hs.messageShown ~= true then - _msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d', - currentTroopCount, troopCount, maxTroops)) - if not hs then - CTLD._hoverState[uname] = { messageShown = true } - else - hs.messageShown = true - end - end - end - end - - -- Check both count AND weight limits - if countOK and weightOK then - local hs = CTLD._hoverState[uname] - if not hs or hs.targetCrate ~= bestName or hs.targetType ~= bestType then - CTLD._hoverState[uname] = { targetCrate = bestName, targetType = bestType, startTime = now } - if coachEnabled then _coachSend(self, group, uname, 'coach_hold', {}, false) end - else - -- stability hold timer - local holdNeeded = coachCfg.thresholds.stabilityHold or 1.8 - if (now - hs.startTime) >= holdNeeded then - -- load it - if bestType == 'crate' then - -- Use shared loading function - local success = self:_loadCrateIntoAircraft(group, bestName, bestMeta) - if success then - if coachEnabled then - _coachSend(self, group, uname, 'coach_loaded', {}, false) - else - _msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key))) - end - end - elseif bestType == 'troops' then - -- Pick up the troop group - local troopGroup = GROUP:FindByName(bestName) - if troopGroup then - troopGroup:Destroy() - end - _removeFromSpatialGrid(bestName, bestMeta.point, 'troops') -- Remove from spatial index - CTLD._deployedTroops[bestName] = nil - - -- ADD to existing troops if any, don't overwrite - local currentTroops = CTLD._troopsLoaded[gname] - if currentTroops then - -- Add to existing load (supports mixing types) - local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } } - table.insert(troopTypes, { typeKey = bestMeta.typeKey, count = bestMeta.count }) - - CTLD._troopsLoaded[gname] = { - count = currentTroops.count + bestMeta.count, - typeKey = 'Mixed', -- Indicate mixed types - troopTypes = troopTypes, -- Store individual type details - weightKg = currentTroops.weightKg + bestMeta.weightKg - } - self:_refreshLoadedTroopSummaryForGroup(gname) - _msgGroup(group, string.format('Loaded %d more troops (total: %d)', bestMeta.count, CTLD._troopsLoaded[gname].count)) - else - -- First load - CTLD._troopsLoaded[gname] = { - count = bestMeta.count, - typeKey = bestMeta.typeKey, - troopTypes = { { typeKey = bestMeta.typeKey, count = bestMeta.count } }, - weightKg = bestMeta.weightKg - } - self:_refreshLoadedTroopSummaryForGroup(gname) - if coachEnabled then - _msgGroup(group, string.format('Loaded %d troops', bestMeta.count)) - else - _msgGroup(group, string.format('Loaded %d troops', bestMeta.count)) - end - end - - -- Update cargo weight - self:_updateCargoWeight(group) - end - CTLD._hoverState[uname] = nil - end - end - else - -- Aircraft at capacity - notify player with weight/count info - local aircraftType = _getUnitType(unit) or 'aircraft' - if not weightOK then - -- Weight limit exceeded - _msgGroup(group, string.format('Weight capacity reached! Current: %dkg, Item: %dkg, Max: %dkg for %s', - math.floor(currentWeight), math.floor(itemWeight), math.floor(maxWeight), aircraftType)) - elseif bestType == 'crate' then - -- Count limit exceeded for crates - _eventSend(self, group, nil, 'crate_aircraft_capacity', { current = total, max = maxCrates, aircraft = aircraftType }) - elseif bestType == 'troops' then - -- Count limit exceeded for troops - _eventSend(self, group, nil, 'troop_aircraft_capacity', { count = bestMeta.count or 0, max = maxTroops, aircraft = aircraftType }) - end - CTLD._hoverState[uname] = nil - end - else - -- lost precision window - if coachEnabled then _coachSend(self, group, uname, 'coach_hover_lost', {}, false) end - CTLD._hoverState[uname] = nil - end - else - -- reset hover state when outside primary envelope - if CTLD._hoverState[uname] then - if coachEnabled then _coachSend(self, group, uname, 'coach_abort', {}, false) end - end - CTLD._hoverState[uname] = nil - end - end - end - end - end - end -end --- #endregion Hover pickup scanner - --- ========================= --- Ground Auto-Load Scanner --- ========================= --- #region Ground auto-load scanner -function CTLD:ScanGroundAutoLoad() - local groundCfg = CTLD.GroundAutoLoadConfig or { Enabled = false } - if not groundCfg.Enabled then return end - - local now = timer.getTime() - local progressMsgInterval = (self.GroundLoadComms and self.GroundLoadComms.ProgressInterval) or 5 - - -- Iterate all groups that have menus (active transports) - for gname, _ in pairs(self.MenusByGroup or {}) do - local group = GROUP:FindByName(gname) - if group and group:IsAlive() then - local unit = group:GetUnit(1) - if unit and unit:IsAlive() then - local typ = _getUnitType(unit) - if _isIn(self.Config.AllowedAircraft, typ) then - local uname = unit:GetName() - - -- Ensure ground-load state table exists - CTLD._groundLoadState = CTLD._groundLoadState or {} - - -- Check if already carrying crates (skip if at capacity) - local carried = CTLD._loadedCrates[gname] - local currentCount = carried and carried.total or 0 - local capacity = _getAircraftCapacity(unit) - - if currentCount < capacity.maxCrates then - -- Check basic requirements: on ground, low speed - local agl = _getUnitAGL(unit) - local gs = _getGroundSpeed(unit) - local onGround = (agl <= (groundCfg.GroundContactAGL or 3.5)) - local slowEnough = (gs <= (groundCfg.MaxGroundSpeed or 2.0)) - - -- If a previous ground auto-load completed while we remain effectively stationary - -- on the ground, avoid auto-starting again until the aircraft has moved or lifted off. - local state = CTLD._groundLoadState[uname] - local canProcess = true - if state and state.completed and onGround and slowEnough then - local p3 = unit:GetPointVec3() - local sx = state.completedPosition and state.completedPosition.x or state.startPosition and state.startPosition.x or p3.x - local sz = state.completedPosition and state.completedPosition.z or state.startPosition and state.startPosition.z or p3.z - local dx = (p3.x - sx) - local dz = (p3.z - sz) - local moved = math.sqrt(dx*dx + dz*dz) - -- Require a small reposition (e.g., taxi or liftoff) before new auto-load cycles - if moved < 20 then - canProcess = false - end - end - - if onGround and slowEnough and canProcess then - -- Check zone requirement - local inValidZone = false - if groundCfg.RequirePickupZone then - local inPickupZone = self:_isUnitInsidePickupZone(unit, true) - - if not inPickupZone and groundCfg.AllowInFOBZones then - -- Check FOB zones too - for _, fobZone in ipairs(self.FOBZones or {}) do - local fname = fobZone:GetName() - if self._ZoneActive.FOB[fname] ~= false then - local fobPoint = fobZone:GetPointVec3() - local unitPoint = unit:GetPointVec3() - local dx = (fobPoint.x - unitPoint.x) - local dz = (fobPoint.z - unitPoint.z) - local d = math.sqrt(dx*dx + dz*dz) - local fobRadius = self:_getZoneRadius(fobZone) - if d <= fobRadius then - inValidZone = true - break - end - end - end - else - inValidZone = inPickupZone - end - else - inValidZone = true -- no zone requirement - end - - if inValidZone then - -- Find nearby crates - local p3 = unit:GetPointVec3() - local searchRadius = groundCfg.SearchRadius or 50 - local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, searchRadius) - - local bestGroundCrate, bestGroundDist = nil, math.huge - local loadableCrates = {} - for name, meta in pairs(nearby.crates or {}) do - if meta.side == self.Side then - local dx = (meta.point.x - p3.x) - local dz = (meta.point.z - p3.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= searchRadius then - local entry = { name = name, meta = meta, dist = d } - table.insert(loadableCrates, entry) - if d < bestGroundDist then - bestGroundCrate, bestGroundDist = entry, d - end - end - end - end - - if CTLD.Config and CTLD.Config.DebugGroundCrates then - local shouldReport = true - local gst = CTLD._groundLoadState and CTLD._groundLoadState[uname] - if gst and gst.reportHold then - -- only emit periodic heartbeat during hold - local last = gst.reportLast or 0 - if (now - last) < (CTLD.Config.DebugGroundCratesInterval or 2.0) then - shouldReport = false - else - CTLD._groundLoadState[uname].reportLast = now - end - end - if shouldReport then - _debugCrateSight('GroundDebug', { - unit = uname, - now = now, - interval = CTLD.Config.DebugGroundCratesInterval, - step = CTLD.Config.DebugGroundCratesStep, - name = bestGroundCrate and bestGroundCrate.name or 'none', - distance = (bestGroundDist ~= math.huge) and bestGroundDist or nil, - count = #loadableCrates, - troops = 0, - radius = searchRadius, - typeHint = 'crate', - note = string.format('freeSlots=%d', math.max(0, capacity.maxCrates - currentCount)) - }) - end - end - - if #loadableCrates > 0 then - -- Sort by distance, load closest first (up to capacity) - table.sort(loadableCrates, function(a, b) return a.dist < b.dist end) - - -- Determine how many we can load - local canLoad = math.min(#loadableCrates, capacity.maxCrates - currentCount) - local cratesToLoad = {} - for i = 1, canLoad do - table.insert(cratesToLoad, loadableCrates[i]) - end - - if #cratesToLoad > 0 then - -- Ground load state machine - local state = CTLD._groundLoadState[uname] - if not state or not state.loading then - -- Start new load sequence - CTLD._groundLoadState[uname] = { - loading = true, - startTime = now, - cratesToLoad = cratesToLoad, - startPosition = { x = p3.x, z = p3.z }, - lastCheckTime = now, - } - local msgData = _prepareGroundLoadMessage(self, 'Start', { - seconds = groundCfg.LoadDelay, - count = #cratesToLoad, - }) - _coachSend(self, group, uname, 'ground_load_started', msgData, false) - else - -- Validate that crates in state still exist - local validCrates = {} - for _, crateInfo in ipairs(state.cratesToLoad or {}) do - -- Check if crate still exists in CTLD._crates - if CTLD._crates[crateInfo.name] then - table.insert(validCrates, crateInfo) - end - end - - -- If no valid crates remain, reset state - if #validCrates == 0 then - CTLD._groundLoadState[uname] = nil - else - -- Update state with only valid crates - state.cratesToLoad = validCrates - - -- Continue existing sequence - local elapsed = now - state.startTime - local remaining = (groundCfg.LoadDelay or 15) - elapsed - - -- Check if moved too much - local dx = (p3.x - state.startPosition.x) - local dz = (p3.z - state.startPosition.z) - local moved = math.sqrt(dx*dx + dz*dz) - if moved > 10 then -- moved more than 10m - _coachSend(self, group, uname, 'ground_load_aborted', {}, false) - CTLD._groundLoadState[uname] = nil - else - -- Progress message every 5 seconds - if (now - state.lastCheckTime) >= progressMsgInterval and remaining > 1 then - local msgData = _prepareGroundLoadMessage(self, 'Progress', { - remaining = math.max(0, math.ceil(remaining)), - }) - _coachSend(self, group, uname, 'ground_load_progress', msgData, false) - state.lastCheckTime = now - state.reportHold = true - end - - -- Check if time elapsed - if remaining <= 0 then - -- Load the crates! - local loadedCount = 0 - for _, crateInfo in ipairs(state.cratesToLoad) do - local success = self:_loadCrateIntoAircraft(group, crateInfo.name, crateInfo.meta) - if success then - loadedCount = loadedCount + 1 - end - end - - if loadedCount > 0 then - local msgData = _prepareGroundLoadMessage(self, 'Complete', { - count = loadedCount, - }) - _coachSend(self, group, uname, 'ground_load_complete', msgData, false) - -- Mark completion and remember where it happened so we don't - -- immediately restart another cycle while still parked. - CTLD._groundLoadState[uname] = { - completed = true, - completedTime = now, - completedPosition = { x = p3.x, z = p3.z }, - reportHold = false, - } - else - -- Nothing actually loaded; clear state fully. - CTLD._groundLoadState[uname] = nil - end - end - end - end -- end of validCrates > 0 - end -- end of state exists check - else - CTLD._groundLoadState[uname] = nil - end - else - CTLD._groundLoadState[uname] = nil - end - else - -- Not in a valid zone - local state = CTLD._groundLoadState[uname] - if state and state.sentZoneWarning ~= true then - -- Send zone requirement message (once) - local nearestZone, nearestDist = self:_nearestActivePickupZone(unit) - if nearestZone and nearestDist then - local isMetric = _getPlayerIsMetric(unit) - local brg = _bearingTo(unit, nearestZone) - local distV, distU = _fmtDistance(nearestDist, isMetric) - _coachSend(self, group, uname, 'ground_load_no_zone', { - zone_dist = distV, - zone_dist_u = distU, - zone_brg = brg - }, false) - end - if not state then - CTLD._groundLoadState[uname] = { sentZoneWarning = true } - else - state.sentZoneWarning = true - end - end - end - else - -- Not on ground or moving too fast, reset state - local state = CTLD._groundLoadState[uname] - if state and state.loading then - -- Was loading but now lifted off or moved - _coachSend(self, group, uname, 'ground_load_aborted', {}, false) - end - CTLD._groundLoadState[uname] = nil - end - else - -- At capacity, no need to check - CTLD._groundLoadState[uname] = nil - end - end - end - end - end -end - --- Helper: Load a crate into an aircraft (shared by hover and ground load) -function CTLD:_loadCrateIntoAircraft(group, crateName, crateMeta) - if not group or not crateName or not crateMeta then return false end - - local gname = group:GetName() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return false end - - -- Destroy the static object first - local obj = StaticObject.getByName(crateName) - if obj then obj:destroy() end - - -- Clean up crate smoke refresh schedule - _cleanupCrateSmoke(crateName) - - -- Remove from spatial grid and tracking - _removeFromSpatialGrid(crateName, crateMeta.point, 'crate') - CTLD._crates[crateName] = nil - - -- Add to loaded crates using existing method (maintains consistency with rest of code) - self:_addLoadedCrate(group, crateMeta.key) - - -- Update inventory if enabled - if self.Config.Inventory and self.Config.Inventory.Enabled and crateMeta.spawnZone then - pcall(function() self:_updateInventoryOnPickup(crateMeta.spawnZone, crateMeta.key) end) - end - - _logDebug(string.format('[Load] Loaded crate %s (%s) into %s', crateName, crateMeta.key, gname)) - return true -end --- #endregion Ground auto-load scanner - --- ========================= --- Troops --- ========================= --- #region Troops -function CTLD:_lookupCrateLabel(crateKey) - if not crateKey then return 'Unknown Crate' end - local cat = self.Config and self.Config.CrateCatalog or {} - local def = cat[crateKey] - if def then - return def.menu or def.description or def.name or def.displayName or crateKey - end - return crateKey -end - -function CTLD:_lookupTroopLabel(typeKey) - if not typeKey or typeKey == '' then return 'Troops' end - local cfg = self.Config and self.Config.Troops and self.Config.Troops.TroopTypes - local def = cfg and cfg[typeKey] - if def and def.label and def.label ~= '' then - return def.label - end - return typeKey -end - -function CTLD:_refreshLoadedTroopSummaryForGroup(groupName) - if not groupName or groupName == '' then return end - local load = CTLD._troopsLoaded[groupName] - if not load or (load.count or 0) == 0 then - CTLD._loadedTroopTypes[groupName] = nil - return - end - - local entries = {} - if load.troopTypes and #load.troopTypes > 0 then - entries = load.troopTypes - else - entries = { { typeKey = load.typeKey, count = load.count } } - end - - local summary = { total = 0, byType = {}, labels = {} } - for _, entry in ipairs(entries) do - local typeKey = entry.typeKey or load.typeKey or 'Troops' - local count = entry.count or 0 - if count > 0 then - summary.byType[typeKey] = (summary.byType[typeKey] or 0) + count - summary.labels[typeKey] = self:_lookupTroopLabel(typeKey) - summary.total = summary.total + count - end - end - - if summary.total > 0 then - CTLD._loadedTroopTypes[groupName] = summary - else - CTLD._loadedTroopTypes[groupName] = nil - end -end - -function CTLD:LoadTroops(group, opts) - local gname = group:GetName() - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - -- Check for MEDEVAC crew pickup first - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - local medevacPickedUp = self:CheckMEDEVACPickup(group) - if medevacPickedUp then - return -- MEDEVAC crew was picked up, don't continue with normal troop loading - end - end - - -- Ground requirement check for troop loading (realistic behavior) - if self.Config.RequireGroundForTroopLoad then - local unitType = _getUnitType(unit) - local capacities = self.Config.AircraftCapacities or {} - local specific = capacities[unitType] - - -- Check per-aircraft override first, then fall back to global config - local requireGround = (specific and specific.requireGround ~= nil) and specific.requireGround or true - - if requireGround then - -- Must be on the ground - if _isUnitInAir(unit) then - local isMetric = _getPlayerIsMetric(unit) - local maxSpeed = (specific and specific.maxGroundSpeed) or self.Config.MaxGroundSpeedForLoading or 2.0 - local speedVal, speedUnit = _fmtSpeed(maxSpeed, isMetric) - _eventSend(self, group, nil, 'troop_load_must_land', { max_speed = speedVal, speed_u = speedUnit }) - return - end - - -- Check ground speed (must not be taxiing too fast) - local groundSpeed = _getGroundSpeed(unit) - local maxSpeed = (specific and specific.maxGroundSpeed) or self.Config.MaxGroundSpeedForLoading or 2.0 - - if groundSpeed > maxSpeed then - local isMetric = _getPlayerIsMetric(unit) - local currentVal, currentUnit = _fmtSpeed(groundSpeed, isMetric) - local maxVal, maxUnit = _fmtSpeed(maxSpeed, isMetric) - _eventSend(self, group, nil, 'troop_load_too_fast', { - current_speed = currentVal, - max_speed = maxVal, - speed_u = maxUnit - }) - return - end - end - end - - -- Check for nearby deployed troops first (allow pickup regardless of zone restrictions) - local p3 = unit:GetPointVec3() - local maxPickupDistance = 25 -- meters for ground-based troop pickup (matches hover pickup distance) - local nearby = _getNearbyFromSpatialGrid(p3.x, p3.z, maxPickupDistance) - - local nearbyTroopGroup = nil - local nearbyTroopMeta = nil - local nearbyTroopDist = nil - - -- Search for nearby deployed troops - for troopGroupName, troopMeta in pairs(nearby.troops) do - if troopMeta.side == self.Side then - local troopGroup = GROUP:FindByName(troopGroupName) - if troopGroup and troopGroup:IsAlive() then - local troopPos = troopGroup:GetCoordinate() - if troopPos then - local tp = troopPos:GetVec3() - local dx = (tp.x - p3.x) - local dz = (tp.z - p3.z) - local d = math.sqrt(dx*dx + dz*dz) - if d <= maxPickupDistance and ((not nearbyTroopDist) or d < nearbyTroopDist) then - nearbyTroopGroup = troopGroup - nearbyTroopMeta = troopMeta - nearbyTroopDist = d - end - end - end - end - end - - -- If we found nearby deployed troops, pick them up (bypass zone requirement) - if nearbyTroopGroup and nearbyTroopMeta then - -- Load the deployed troops - local troopCount = nearbyTroopMeta.count or 0 - local typeKey = nearbyTroopMeta.typeKey or 'AS' - local troopWeight = nearbyTroopMeta.weightKg or 0 - - -- Check aircraft capacity - local capacity = _getAircraftCapacity(unit) - local maxTroops = capacity.maxTroops - local maxWeight = capacity.maxWeightKg or 0 - - local currentTroops = CTLD._troopsLoaded[gname] - local currentTroopCount = currentTroops and currentTroops.count or 0 - local totalTroopCount = currentTroopCount + troopCount - - local carried = CTLD._loadedCrates[gname] - local currentWeight = carried and carried.totalWeightKg or 0 - local wouldBeWeight = currentWeight + troopWeight - - -- Check capacity - if totalTroopCount > maxTroops then - local aircraftType = _getUnitType(unit) or 'aircraft' - _msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d for %s', - currentTroopCount, troopCount, maxTroops, aircraftType)) - return - end - - if maxWeight > 0 and wouldBeWeight > maxWeight then - local aircraftType = _getUnitType(unit) or 'aircraft' - _msgGroup(group, string.format('Weight capacity exceeded! Current: %dkg, Troops: %dkg, Max: %dkg for %s', - math.floor(currentWeight), math.floor(troopWeight), math.floor(maxWeight), aircraftType)) - return - end - - -- Load the troops and remove from deployed tracking - if currentTroops then - local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } } - table.insert(troopTypes, { typeKey = typeKey, count = troopCount }) - - CTLD._troopsLoaded[gname] = { - count = totalTroopCount, - typeKey = 'Mixed', - troopTypes = troopTypes, - weightKg = currentTroops.weightKg + troopWeight, - } - else - CTLD._troopsLoaded[gname] = { - count = troopCount, - typeKey = typeKey, - troopTypes = { { typeKey = typeKey, count = troopCount } }, - weightKg = troopWeight, - } - end - - -- Remove from deployed tracking - local troopGroupName = nearbyTroopGroup:GetName() - _removeFromSpatialGrid(troopGroupName, nearbyTroopMeta.point, 'troops') - CTLD._deployedTroops[troopGroupName] = nil - - -- Destroy the troop group - nearbyTroopGroup:Destroy() - - self:_refreshLoadedTroopSummaryForGroup(gname) - _eventSend(self, group, nil, 'troops_loaded', { count = totalTroopCount }) - _msgGroup(group, string.format('Picked up %d deployed troops (total onboard: %d)', troopCount, totalTroopCount)) - - return -- Successfully picked up deployed troops, exit function - end - - -- No nearby deployed troops found, enforce pickup zone requirement for spawning new troops - if self.Config.RequirePickupZoneForTroopLoad then - local hasPickupZones = (self.PickupZones and #self.PickupZones > 0) or (self.Config.Zones and self.Config.Zones.PickupZones and #self.Config.Zones.PickupZones > 0) - if not hasPickupZones then - _eventSend(self, group, nil, 'no_pickup_zones', {}) - return - end - local zone, dist = self:_nearestActivePickupZone(unit) - if not zone or not dist then - -- No active pickup zone resolvable; provide helpful vectors to nearest configured zone if any - local list = {} - if self.Config and self.Config.Zones and self.Config.Zones.PickupZones then - for _, z in ipairs(self.Config.Zones.PickupZones) do table.insert(list, z) end - elseif self.PickupZones and #self.PickupZones > 0 then - for _, mz in ipairs(self.PickupZones) do if mz and mz.GetName then table.insert(list, { name = mz:GetName() }) end end - end - local fbZone, fbDist = _nearestZonePoint(unit, list) - if fbZone and fbDist then - local isMetric = _getPlayerIsMetric(unit) - local rZone = self:_getZoneRadius(fbZone) or 0 - local delta = math.max(0, fbDist - rZone) - local v, u = _fmtRange(delta, isMetric) - local up = unit:GetPointVec3(); local zp = fbZone:GetPointVec3() - local brg = _bearingDeg({ x = up.x, z = up.z }, { x = zp.x, z = zp.z }) - _eventSend(self, group, nil, 'troop_pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) - else - _eventSend(self, group, nil, 'no_pickup_zones', {}) - end - return - end - local inside = false - if zone then - local rZone = self:_getZoneRadius(zone) or 0 - if dist and rZone and dist <= rZone then inside = true end - end - if not inside then - local isMetric = _getPlayerIsMetric(unit) - local rZone = (self:_getZoneRadius(zone) or 0) - local delta = (dist and rZone) and math.max(0, dist - rZone) or 0 - local v, u = _fmtRange(delta, isMetric) - -- Bearing from player to zone center - local up = unit:GetPointVec3() - local zp = zone and zone:GetPointVec3() or nil - local brg = 0 - if zp then - brg = _bearingDeg({ x = up.x, z = up.z }, { x = zp.x, z = zp.z }) - end - _eventSend(self, group, nil, 'troop_pickup_zone_required', { zone_dist = v, zone_dist_u = u, zone_brg = brg }) - return - end - end - - -- Determine troop type and composition - local requestedType = (opts and (opts.typeKey or opts.type)) - or (self.Config.Troops and self.Config.Troops.DefaultType) - or 'AS' - local unitsList, label = self:_resolveTroopUnits(requestedType) - local troopDef = (self.Config.Troops and self.Config.Troops.TroopTypes and self.Config.Troops.TroopTypes[requestedType]) or nil - - -- Check if we already have troops (allow mixing different types now) - local currentTroops = CTLD._troopsLoaded[gname] - - -- Check aircraft capacity for troops - local capacity = _getAircraftCapacity(unit) - local maxTroops = capacity.maxTroops - local maxWeight = capacity.maxWeightKg or 0 - local troopCount = #unitsList - - -- Calculate troop weight from catalog - local troopWeight = 0 - if troopDef and troopDef.weightKg then - troopWeight = troopDef.weightKg - elseif troopCount > 0 then - -- Fallback: estimate 100kg per soldier if no weight defined - troopWeight = troopCount * 100 - end - - -- Check current cargo weight and troop count - local carried = CTLD._loadedCrates[gname] - local currentWeight = carried and carried.totalWeightKg or 0 - local currentTroopCount = currentTroops and currentTroops.count or 0 - local totalTroopCount = currentTroopCount + troopCount - local wouldBeWeight = currentWeight + troopWeight - - -- Check total troop count limit - if totalTroopCount > maxTroops then - -- Aircraft cannot carry this many troops total - local aircraftType = _getUnitType(unit) or 'aircraft' - _msgGroup(group, string.format('Troop capacity exceeded! Current: %d, Adding: %d, Max: %d for %s', - currentTroopCount, troopCount, maxTroops, aircraftType)) - return - end - - -- Check weight limit (if enabled) - if maxWeight > 0 and wouldBeWeight > maxWeight then - -- Weight capacity exceeded - local aircraftType = _getUnitType(unit) or 'aircraft' - _msgGroup(group, string.format('Weight capacity exceeded! Current: %dkg, Troops: %dkg, Max: %dkg for %s', - math.floor(currentWeight), math.floor(troopWeight), math.floor(maxWeight), aircraftType)) - return - end - - -- ADD to existing troops or create new entry - if currentTroops then - -- Add to existing load (supports mixing types) - local troopTypes = currentTroops.troopTypes or { { typeKey = currentTroops.typeKey, count = currentTroops.count } } - table.insert(troopTypes, { typeKey = requestedType, count = troopCount }) - - CTLD._troopsLoaded[gname] = { - count = totalTroopCount, - typeKey = 'Mixed', -- Indicate mixed types - troopTypes = troopTypes, -- Store individual type details - weightKg = currentTroops.weightKg + troopWeight, - } - self:_refreshLoadedTroopSummaryForGroup(gname) - _eventSend(self, group, nil, 'troops_loaded', { count = totalTroopCount }) - _msgGroup(group, string.format('Loaded %d more troops (total: %d)', troopCount, totalTroopCount)) - else - CTLD._troopsLoaded[gname] = { - count = troopCount, - typeKey = requestedType, - troopTypes = { { typeKey = requestedType, count = troopCount } }, - weightKg = troopWeight, - } - self:_refreshLoadedTroopSummaryForGroup(gname) - _eventSend(self, group, nil, 'troops_loaded', { count = troopCount }) - end - - -- Update DCS internal cargo weight - self:_updateCargoWeight(group) -end - -function CTLD:UnloadTroops(group, opts) - local gname = group:GetName() - local load = CTLD._troopsLoaded[gname] - if not load or (load.count or 0) == 0 then _eventSend(self, group, nil, 'no_troops', {}) return end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - -- Check for MEDEVAC crew delivery to MASH first - if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then - local medevacStatus = self:CheckMEDEVACDelivery(group, load) - if medevacStatus == 'delivered' then - -- Crew delivered to MASH, clear troops and return - CTLD._troopsLoaded[gname] = nil - CTLD._loadedTroopTypes[gname] = nil - - -- Update DCS internal cargo weight after delivery - self:_updateCargoWeight(group) - - return - elseif medevacStatus == 'pending' then - return - end - end - - -- Determine if unit is in the air and check for fast-rope capability - local isInAir = _isUnitInAir(unit) - local canFastRope = false - local isFastRope = false - - if isInAir then - -- Unit is airborne - check if fast-rope is enabled and if altitude is safe - if self.Config.EnableFastRope then - local p3 = unit:GetPointVec3() - local ground = land and land.getHeight and land.getHeight({x = p3.x, y = p3.z}) or 0 - local agl = p3.y - ground - local maxFastRopeAGL = self.Config.FastRopeMaxHeight or 20 - local minFastRopeAGL = self.Config.FastRopeMinHeight or 5 - - if agl > maxFastRopeAGL then - -- Too high for fast-rope - local isMetric = _getPlayerIsMetric(unit) - local aglDisplay = _fmtAGL(agl, isMetric) - _eventSend(self, group, nil, 'troop_unload_altitude_too_high', { - max_agl = math.floor(maxFastRopeAGL), - current_agl = math.floor(agl) - }) - return - elseif agl < minFastRopeAGL then - -- Too low for safe fast-rope - _eventSend(self, group, nil, 'troop_unload_altitude_too_low', { - min_agl = math.floor(minFastRopeAGL), - current_agl = math.floor(agl) - }) - return - else - -- Within safe fast-rope window - canFastRope = true - isFastRope = true - end - else - -- Fast-rope disabled - must land - _msgGroup(group, "Must land to deploy troops. Fast-rope is disabled.", 10) - return - end - end - - -- Restrict deploying troops inside Pickup Zones if configured - if self.Config.ForbidTroopDeployInsidePickupZones then - local activeOnly = (self.Config.ForbidChecksActivePickupOnly ~= false) - local inside = false - local ok, _ = pcall(function() - inside = select(1, self:_isUnitInsidePickupZone(unit, activeOnly)) - end) - if ok and inside then - _eventSend(self, group, nil, 'troop_deploy_forbidden_in_pickup', {}) - return - end - end - - local p = unit:GetPointVec3() - local here = { x = p.x, z = p.z } - local hdgRad, _ = _headingRadDeg(unit) - -- Offset troop spawn forward to avoid spawning under/near rotors - local troopOffset = math.max(0, tonumber(self.Config.TroopSpawnOffset or 0) or 0) - local center = (troopOffset > 0) and { x = here.x + math.sin(hdgRad) * troopOffset, z = here.z + math.cos(hdgRad) * troopOffset } or { x = here.x, z = here.z } - - -- Build the unit composition - handle mixed troop types - local units = {} - local spacing = 1.8 - local unitIndex = 0 - - if load.troopTypes then - -- Mixed types - spawn each type's units - for _, troopTypeData in ipairs(load.troopTypes) do - local comp, _ = self:_resolveTroopUnits(troopTypeData.typeKey) - for i=1, #comp do - unitIndex = unitIndex + 1 - local dx = (unitIndex-1) * spacing - local dz = ((unitIndex % 2) == 0) and 2.0 or -2.0 - table.insert(units, { - type = tostring(comp[i] or 'Infantry AK'), - name = string.format('CTLD-TROOP-%d', math.random(100000,999999)), - x = center.x + dx, y = center.z + dz, heading = hdgRad - }) - end - end - else - -- Single type (legacy support) - local comp, _ = self:_resolveTroopUnits(load.typeKey) - for i=1, #comp do - unitIndex = unitIndex + 1 - local dx = (unitIndex-1) * spacing - local dz = ((unitIndex % 2) == 0) and 2.0 or -2.0 - table.insert(units, { - type = tostring(comp[i] or 'Infantry AK'), - name = string.format('CTLD-TROOP-%d', math.random(100000,999999)), - x = center.x + dx, y = center.z + dz, heading = hdgRad - }) - end - end - - local groupData = { - visible=false, lateActivation=false, tasks={}, task='Ground Nothing', - units=units, route={}, name=string.format('CTLD_TROOPS_%d', math.random(100000,999999)) - } - local spawned = _coalitionAddGroup(self.Side, Group.Category.GROUND, groupData, self.Config) - if spawned then - -- Track deployed troop groups for later pickup - local troopGroupName = spawned:getName() - CTLD._deployedTroops[troopGroupName] = { - typeKey = load.typeKey, - count = load.count, - side = self.Side, - spawnTime = timer.getTime(), - point = { x = center.x, z = center.z }, - weightKg = load.weightKg or 0, - behavior = opts and opts.behavior or 'defend' - } - -- Add to spatial index for efficient hover pickup - _addToSpatialGrid(troopGroupName, CTLD._deployedTroops[troopGroupName], 'troops') - - CTLD._troopsLoaded[gname] = nil - CTLD._loadedTroopTypes[gname] = nil - - -- Update DCS internal cargo weight after unloading troops - self:_updateCargoWeight(group) - - -- Send appropriate message based on deployment method - if isFastRope then - local aircraftType = _getUnitType(unit) or 'aircraft' - _eventSend(self, nil, self.Side, 'troops_fast_roped_coalition', { - count = #units, - player = _playerNameFromGroup(group), - aircraft = aircraftType - }) - else - _eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) }) - end - - -- Assign optional behavior - local behavior = opts and opts.behavior or nil - _logDebug(string.format("TROOP DEPLOY: Group '%s' spawned with behavior='%s'", - spawned:getName(), tostring(behavior))) - - if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then - _logDebug(string.format("TROOP DEPLOY: Initiating attack behavior for '%s'", spawned:getName())) - local t = self:_assignAttackBehavior(spawned:getName(), center, false) - -- Announce intentions globally - local isMetric = _getPlayerIsMetric(group:GetUnit(1)) - if t and t.kind == 'base' then - local brg = _bearingDeg({ x = center.x, z = center.z }, { x = t.point.x, z = t.point.z }) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_base_announce', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), base_name = t.name, brg = brg, rng = v, rng_u = u }) - elseif t and t.kind == 'enemy' then - local brg = _bearingDeg({ x = center.x, z = center.z }, { x = t.point.x, z = t.point.z }) - local v, u = _fmtRange(t.dist or 0, isMetric) - _eventSend(self, nil, self.Side, 'attack_enemy_announce', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), enemy_type = t.etype or 'unit', brg = brg, rng = v, rng_u = u }) - else - local v, u = _fmtRange((self.Config.AttackAI and self.Config.AttackAI.TroopSearchRadius) or 3000, isMetric) - _eventSend(self, nil, self.Side, 'attack_no_targets', { unit_name = spawned:getName(), player = _playerNameFromGroup(group), rng = v, rng_u = u }) - end - end - else - _eventSend(self, group, nil, 'troops_deploy_failed', { reason = 'DCS group spawn error' }) - end -end --- #endregion Troops - --- Internal: resolve troop composition list for a given type key and coalition -function CTLD:_resolveTroopUnits(typeKey) - local tcfg = (self.Config.Troops and self.Config.Troops.TroopTypes) or {} - local def = tcfg[typeKey or 'AS'] or {} - - -- Log warning if troop types are missing - if not def or not def.size then - _logError(string.format('WARNING: Troop type "%s" not found or incomplete. TroopTypes table has %d entries.', - typeKey or 'AS', - (tcfg and type(tcfg) == 'table') and #tcfg or 0)) - end - - local size = tonumber(def.size or 0) or 0 - if size <= 0 then size = 6 end - local pool - if self.Side == coalition.side.BLUE then - pool = def.unitsBlue or def.units - elseif self.Side == coalition.side.RED then - pool = def.unitsRed or def.units - else - pool = def.units - end - if not pool or #pool == 0 then pool = { 'Infantry AK' } end - - -- Debug: Log what units will spawn - local unitList = {} - for i=1,math.min(size, 3) do - table.insert(unitList, pool[((i-1) % #pool) + 1]) - end - _logDebug(string.format('Spawning %d troops for type "%s": %s%s', - size, - typeKey or 'AS', - table.concat(unitList, ', '), - size > 3 and '...' or '')) - - local list = {} - for i=1,size do list[i] = pool[((i-1) % #pool) + 1] end - local label = def.label or typeKey or 'Troops' - return list, label -end - --- ========================= --- Public helpers --- ========================= --- ========================= --- Auto-build FOB in zones --- ========================= --- #region Auto-build FOB in zones -function CTLD:AutoBuildFOBCheck() - if not (self.FOBZones and #self.FOBZones > 0) then return end - -- Find any FOB recipe definitions - local fobDefs = {} - for key,def in pairs(self.Config.CrateCatalog) do if def.isFOB and def.build then fobDefs[key] = def end end - if next(fobDefs) == nil then return end - - for _,zone in ipairs(self.FOBZones) do - local center = zone:GetPointVec3() - local radius = self:_getZoneRadius(zone) - local nearby = self:GetNearbyCrates({ x = center.x, z = center.z }, radius) - -- filter to this coalition side - local filtered = {} - for _,c in ipairs(nearby) do if c.meta.side == self.Side then table.insert(filtered, c) end end - nearby = filtered - - if #nearby > 0 then - local counts = {} - for _,c in ipairs(nearby) do counts[c.meta.key] = (counts[c.meta.key] or 0) + 1 end - - local function consumeCrates(key, qty) - local removed = 0 - for _,c in ipairs(nearby) do - if removed >= qty then break end - if c.meta.key == key then - local obj = StaticObject.getByName(c.name) - if obj then obj:destroy() end - _cleanupCrateSmoke(c.name) -- Clean up smoke refresh schedule - CTLD._crates[c.name] = nil - removed = removed + 1 - end - end - end - - local built = false - - -- Prefer composite recipes - for recipeKey,cat in pairs(fobDefs) do - if type(cat.requires) == 'table' then - local ok = true - for reqKey,qty in pairs(cat.requires) do if (counts[reqKey] or 0) < qty then ok = false; break end end - if ok then - local gdata = cat.build({ x = center.x, z = center.z }, 0, cat.side or self.Side) - local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) - if g then - for reqKey,qty in pairs(cat.requires) do consumeCrates(reqKey, qty) end - _msgCoalition(self.Side, string.format('FOB auto-built at %s', zone:GetName())) - built = true - break -- move to next zone; avoid multiple builds per tick - end - end - end - end - - -- Then single-key FOB recipes - if not built then - for key,cat in pairs(fobDefs) do - if not cat.requires and (counts[key] or 0) >= (cat.required or 1) then - local gdata = cat.build({ x = center.x, z = center.z }, 0, cat.side or self.Side) - local g = _coalitionAddGroup(cat.side or self.Side, cat.category or Group.Category.GROUND, gdata, self.Config) - if g then - consumeCrates(key, cat.required or 1) - _msgCoalition(self.Side, string.format('FOB auto-built at %s', zone:GetName())) - built = true - break - end - end - end - end - -- next zone iteration continues automatically - end - end -end --- #endregion Auto-build FOB in zones - --- ========================= --- Public helpers --- ========================= --- #region Public helpers -function CTLD:RegisterCrate(key, def) - self.Config.CrateCatalog[key] = def -end - -function CTLD:MergeCatalog(tbl) - for k,v in pairs(tbl or {}) do self.Config.CrateCatalog[k] = v end -end - --- ========================= --- Inventory helpers --- ========================= --- #region Inventory helpers -function CTLD:InitInventory() - if not (self.Config.Inventory and self.Config.Inventory.Enabled) then return end - -- Seed stock for each configured pickup zone (by name only) - for _,z in ipairs(self.PickupZones or {}) do - local name = z:GetName() - self:_SeedZoneStock(name, 1.0) - end -end - -function CTLD:_SeedZoneStock(zoneName, factor) - if not zoneName then return end - CTLD._stockByZone[zoneName] = CTLD._stockByZone[zoneName] or {} - local f = factor or 1.0 - for key,def in pairs(self.Config.CrateCatalog or {}) do - local n = tonumber(def.initialStock or 0) or 0 - n = math.max(0, math.floor(n * f + 0.0001)) - -- Only seed if not already present (avoid overwriting saved/progress state) - if CTLD._stockByZone[zoneName][key] == nil then - CTLD._stockByZone[zoneName][key] = n - end - end -end - -function CTLD:_CreateFOBPickupZone(point, cat, hdg) - -- Create a small pickup zone at the FOB to act as a supply point - local name = string.format('FOB_PZ_%d', math.random(100000,999999)) - local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(point.x, point.z) or { x = point.x, y = point.z } - local r = 150 - local z = ZONE_RADIUS:New(name, v2, r) - table.insert(self.PickupZones, z) - self._ZoneDefs.PickupZones[name] = { name = name, radius = r, active = true } - self._ZoneActive.Pickup[name] = true - table.insert(self.Config.Zones.PickupZones, { name = name, radius = r, active = true }) - -- Seed FOB stock at fraction of initial pickup stock - local f = (self.Config.Inventory and self.Config.Inventory.FOBStockFactor) or 0.25 - self:_SeedZoneStock(name, f) - _msgCoalition(self.Side, string.format('FOB supply established: %s (stock seeded at %d%%)', name, math.floor(f*100+0.5))) - -- Auto-refresh map drawings so the new FOB pickup zone is visible immediately - if self.Config.MapDraw and self.Config.MapDraw.Enabled then - local ok, err = pcall(function() self:DrawZonesOnMap() end) - if not ok then - _logError(string.format('DrawZonesOnMap failed after FOB creation: %s', tostring(err))) - end - end -end --- #endregion Inventory helpers - --- ========================= --- FARP System --- ========================= --- #region FARP - --- Initialize FARP system (called from CTLD:New) -function CTLD:InitFARP() - if not (CTLD.FARPConfig and CTLD.FARPConfig.Enabled) then return end - _logInfo('FARP system initialized') -end - --- Get FARP data for a FOB zone -function CTLD:GetFARPData(zoneName) - if not zoneName then return nil end - return CTLD._farpData[zoneName] -end - --- Find nearest FOB pickup zone to a point -function CTLD:FindNearestFOBZone(point) - local nearestZone = nil - local nearestDist = math.huge - - for _, zone in ipairs(self.PickupZones or {}) do - local zname = zone:GetName() - -- Check if this is a FOB zone (starts with FOB_PZ_) - if zname and zname:match('^FOB_PZ_') then - local zoneCenter = zone:GetVec2() - local dist = ((point.x - zoneCenter.x)^2 + (point.z - zoneCenter.y)^2)^0.5 - local radius = self:_getZoneRadius(zone) - - if dist < (radius + 50) and dist < nearestDist then - nearestZone = zone - nearestDist = dist - end - end - end - - return nearestZone, nearestDist -end - --- Spawn static objects for a FARP stage -function CTLD:SpawnFARPStatics(zoneName, stage, centerPoint, coalition) - if not (CTLD.FARPConfig and CTLD.FARPConfig.StageLayouts[stage]) then - _logError(string.format('Invalid FARP stage %d or missing layout config', stage)) - return false - end - - local layout = CTLD.FARPConfig.StageLayouts[stage] - local farpData = CTLD._farpData[zoneName] or { stage = 0, statics = {}, coalition = coalition } - - _logInfo(string.format('Spawning FARP Stage %d statics for zone %s (coalition %d)', stage, zoneName, coalition)) - - -- Get coalition name for DCS - -- Note: 'coalition' parameter is a number (1=red, 2=blue), not the coalition table - local coalitionName = (coalition == 2) and 'blue' or 'red' - - for _, obj in ipairs(layout) do - -- Calculate world position from relative offset - local worldX = centerPoint.x + obj.x - local worldZ = centerPoint.z + obj.z - local worldY = land.getHeight({x = worldX, y = worldZ}) - - -- Generate unique name - local staticName = string.format('FARP_%s_S%d_%s_%d', zoneName, stage, obj.type:gsub('%s+', '_'), math.random(10000, 99999)) - - -- Create static object data - local staticData = { - ["type"] = obj.type, - ["name"] = staticName, - ["heading"] = math.rad(obj.heading or 0), - ["x"] = worldX, - ["y"] = worldZ, - ["category"] = "Fortifications", - ["canCargo"] = false, - ["shape_name"] = "", - ["rate"] = 100, - } - - -- Spawn the static - local success, staticObj = pcall(function() - return coalition.addStaticObject(coalition, staticData) - end) - - if success and staticObj then - table.insert(farpData.statics, staticName) - _logDebug(string.format('Spawned FARP static: %s at (%.1f, %.1f)', staticName, worldX, worldZ)) - else - _logError(string.format('Failed to spawn FARP static: %s (%s)', obj.type, tostring(staticObj))) - end - end - - farpData.stage = stage - farpData.coalition = coalition - CTLD._farpData[zoneName] = farpData - - _logInfo(string.format('FARP Stage %d complete for zone %s - spawned %d statics', stage, zoneName, #farpData.statics)) - return true -end - --- Upgrade a FOB to the next FARP stage -function CTLD:UpgradeFARP(group, zoneName) - if not (CTLD.FARPConfig and CTLD.FARPConfig.Enabled) then - MESSAGE:New('FARP system is disabled.', 10):ToGroup(group) - return - end - - local farpData = CTLD._farpData[zoneName] or { stage = 0, statics = {}, coalition = self.Side } - local currentStage = farpData.stage or 0 - local nextStage = currentStage + 1 - - -- Check if already maxed - if nextStage > 3 then - _eventSend(self, group, nil, 'farp_already_maxed', {}) - return - end - - -- Get upgrade cost - local upgradeCost = CTLD.FARPConfig.StageCosts[nextStage] - if not upgradeCost then - MESSAGE:New(string.format('Invalid FARP stage %d', nextStage), 10):ToGroup(group) - return - end - - -- Check salvage points - local currentSalvage = CTLD._salvagePoints[self.Side] or 0 - if currentSalvage < upgradeCost then - _eventSend(self, group, nil, 'farp_upgrade_insufficient_salvage', { - stage = nextStage, - need = upgradeCost, - current = currentSalvage - }) - return - end - - -- Find the zone to get center point - local zone = nil - for _, z in ipairs(self.PickupZones or {}) do - if z:GetName() == zoneName then - zone = z - break - end - end - - if not zone then - MESSAGE:New('FOB zone not found!', 10):ToGroup(group) - return - end - - local center = zone:GetVec2() - local centerPoint = { x = center.x, z = center.y } - - -- Deduct salvage - CTLD._salvagePoints[self.Side] = currentSalvage - upgradeCost - - -- Spawn statics for this stage - _eventSend(self, group, nil, 'farp_upgrade_started', { stage = nextStage }) - - local success = self:SpawnFARPStatics(zoneName, nextStage, centerPoint, self.Side) - - if success then - -- Determine services available - local services = {} - if nextStage >= 1 then table.insert(services, 'Landing Zone') end - if nextStage >= 2 then table.insert(services, 'Refuel') end - if nextStage >= 3 then - table.insert(services, 'Rearm') - table.insert(services, 'Repair') - end - - _eventSend(self, nil, self.Side, 'farp_upgrade_complete', { - player = _playerNameFromGroup(group), - stage = nextStage, - services = table.concat(services, ', ') - }) - - -- Create or update FARP service zone - self:CreateFARPServiceZone(zoneName, centerPoint, nextStage) - - _logInfo(string.format('%s upgraded FOB %s to FARP Stage %d (cost: %d salvage)', - _playerNameFromGroup(group), zoneName, nextStage, upgradeCost)) - else - -- Refund salvage on failure - CTLD._salvagePoints[self.Side] = currentSalvage - MESSAGE:New('FARP upgrade failed! Salvage refunded.', 15):ToGroup(group) - end -end - --- Create FARP service zone for rearm/refuel -function CTLD:CreateFARPServiceZone(zoneName, centerPoint, stage) - if stage < 2 then return end -- Only stages 2+ have services - - local radius = CTLD.FARPConfig.ServiceRadius[stage] or 50 - local farpZoneName = string.format('%s_FARP_Service', zoneName) - - -- Create zone - local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(centerPoint.x, centerPoint.z) or { x = centerPoint.x, y = centerPoint.z } - local serviceZone = ZONE_RADIUS:New(farpZoneName, v2, radius) - - CTLD._farpZones[farpZoneName] = { - zone = serviceZone, - side = self.Side, - stage = stage, - parentFOB = zoneName - } - - -- Start service scheduler - self:StartFARPServices(farpZoneName) - - _logInfo(string.format('Created FARP service zone %s (radius: %dm, stage: %d)', farpZoneName, radius, stage)) -end - --- Start FARP service scheduler -function CTLD:StartFARPServices(farpZoneName) - local farpInfo = CTLD._farpZones[farpZoneName] - if not farpInfo then return end - - local selfref = self - - -- Service scheduler runs every 5 seconds - SCHEDULER:New(nil, function() - local zone = farpInfo.zone - if not zone then return end - - local stage = farpInfo.stage - local units = zone:GetScannedUnits() - - for _, unit in ipairs(units or {}) do - if unit and unit:IsAlive() then - local unitCoalition = unit:GetCoalition() - - -- Only service friendly units - if unitCoalition == farpInfo.side then - local unitType = unit:GetTypeName() - local group = unit:GetGroup() - - -- Service helicopters and ground vehicles - if group and (unit:IsHelicopter() or unit:IsGround()) then - -- Stage 2+: Refuel - if stage >= 2 then - -- Trigger refuel (DCS built-in command) - pcall(function() - local controller = unit:GetUnit():getController() - if controller then - controller:setCommand({ - id = 'RefuelInFlight', - params = {} - }) - end - end) - end - - -- Stage 3: Rearm and Repair - if stage >= 3 then - pcall(function() - local dcsUnit = unit:GetUnit() - if dcsUnit then - -- Note: DCS doesn't have direct Lua API for ground rearm/repair - -- This simulates the presence of the service zone - -- In practice, DCS may auto-service units near FARP statics - end - end) - end - end - end - end - end - end, {}, 0, 5) -- Start immediately, repeat every 5 seconds - - _logDebug(string.format('FARP service scheduler started for %s', farpZoneName)) -end - --- Show FARP status for nearby FOB -function CTLD:ShowFARPStatus(group) - local unit = group:GetUnit(1) - if not unit then return end - - local pos = unit:GetVec3() - local point = { x = pos.x, z = pos.z } - - local fobZone, dist = self:FindNearestFOBZone(point) - - if not fobZone then - _eventSend(self, group, nil, 'farp_not_at_fob', {}) - return - end - - local zoneName = fobZone:GetName() - local farpData = CTLD._farpData[zoneName] or { stage = 0 } - local currentStage = farpData.stage or 0 - - if currentStage >= 3 then - -- Fully upgraded - local services = 'Landing Zone, Refuel, Rearm, Repair' - _eventSend(self, group, nil, 'farp_status_maxed', { - stage = currentStage, - max_stage = 3, - services = services - }) - elseif currentStage > 0 then - -- Partially upgraded - local services = {} - if currentStage >= 1 then table.insert(services, 'Landing Zone') end - if currentStage >= 2 then table.insert(services, 'Refuel') end - if currentStage >= 3 then - table.insert(services, 'Rearm') - table.insert(services, 'Repair') - end - - local nextStage = currentStage + 1 - local nextCost = CTLD.FARPConfig.StageCosts[nextStage] or 0 - - _eventSend(self, group, nil, 'farp_status', { - stage = currentStage, - max_stage = 3, - services = table.concat(services, ', '), - next_cost = nextCost, - next_stage = nextStage - }) - else - -- Base FOB, not yet upgraded - local nextCost = CTLD.FARPConfig.StageCosts[1] or 0 - MESSAGE:New(string.format('FOB Status: Base FOB (not upgraded)\nUpgrade to FARP Stage 1 for %d salvage points.\n\nCurrent salvage: %d', - nextCost, CTLD._salvagePoints[self.Side] or 0), 15):ToGroup(group) - end -end - --- Request FARP upgrade from menu -function CTLD:RequestFARPUpgrade(group) - local unit = group:GetUnit(1) - if not unit then return end - - local pos = unit:GetVec3() - local point = { x = pos.x, z = pos.z } - - local fobZone, dist = self:FindNearestFOBZone(point) - - if not fobZone then - _eventSend(self, group, nil, 'farp_not_at_fob', {}) - return - end - - local zoneName = fobZone:GetName() - self:UpgradeFARP(group, zoneName) -end - --- #endregion FARP - --- ========================= --- Sling-Load Salvage - Manual Crate Support --- ========================= --- #region Manual Salvage Crates - --- Scan mission editor for pre-placed cargo statics and register them as salvage -function CTLD:ScanAndRegisterManualSalvageCrates() - local cfg = self.Config.SlingLoadSalvage - if not (cfg and cfg.Enabled and cfg.EnableManualCrates) then return end - - local prefix = cfg.ManualCratePrefix or 'SALVAGE-' - local registered = 0 - - _logInfo('[ManualSalvage] Scanning for pre-placed salvage crates...') - - -- Get all static objects in the mission - local allStatics = {} - for _, coalitionSide in pairs({coalition.side.BLUE, coalition.side.RED, coalition.side.NEUTRAL}) do - local groups = coalition.getStaticObjects(coalitionSide) or {} - for _, static in pairs(groups) do - table.insert(allStatics, {obj = static, side = coalitionSide}) - end - end - - for _, staticData in ipairs(allStatics) do - local static = staticData.obj - local staticSide = staticData.side - - if static and static:isExist() then - local staticName = static:getName() - - -- Check if name starts with salvage prefix - if staticName and staticName:sub(1, #prefix) == prefix then - -- Check if it's a slingloadable cargo type - local typeName = static:getTypeName() - local isCargo = false - for _, cargoType in ipairs(cfg.CargoTypes or {}) do - if typeName == cargoType then - isCargo = true - break - end - end - - if isCargo then - -- Parse the name to extract information - -- Expected format: SALVAGE-{B|R}-{WEIGHT}KG-{ID} - -- Example: SALVAGE-B-2000KG-CRASH01 - local sideChar, weightStr, id = staticName:match('^SALVAGE%-([BR])%-(%d+)KG%-(.+)$') - - if sideChar and weightStr then - local collectingSide = (sideChar == 'B') and coalition.side.BLUE or coalition.side.RED - local weight = tonumber(weightStr) or 1000 - - -- Calculate reward based on weight class - local rewardPer500kg = 3 -- default medium rate - for _, wc in ipairs(cfg.WeightClasses or {}) do - if weight >= wc.min and weight <= wc.max then - rewardPer500kg = wc.rewardPer500kg or 3 - break - end - end - local rewardValue = math.floor((weight / 500) * rewardPer500kg) - - -- Get position - local pos = static:getPoint() - local position = {x = pos.x, z = pos.z} - - -- Register the crate (no expiration for manual crates) - CTLD._salvageCrates[staticName] = { - side = collectingSide, - weight = weight, - spawnTime = timer.getTime(), - position = position, - initialHealth = 1.0, - rewardValue = rewardValue, - warningsSent = {}, - staticObject = static, - crateClass = 'Manual', - isManual = true, -- Flag to skip expiration checks - } - - registered = registered + 1 - _logInfo(string.format('[ManualSalvage] Registered: %s (Side=%s, Weight=%dkg, Reward=%dpts)', - staticName, sideChar, weight, rewardValue)) - else - _logVerbose(string.format('[ManualSalvage] Skipping %s - invalid name format (use: SALVAGE-{B|R}-####KG-ID)', staticName)) - end - else - _logVerbose(string.format('[ManualSalvage] Skipping %s - not a cargo type (found: %s)', staticName, tostring(typeName))) - end - end - end - end - - if registered > 0 then - _logInfo(string.format('[ManualSalvage] Registered %d manual salvage crate(s)', registered)) - _msgCoalition(self.Side, _fmtTemplate(self.Messages.slingload_manual_crates_registered, {count = registered})) - else - _logInfo('[ManualSalvage] No manual salvage crates found') - end -end - --- #endregion Manual Salvage Crates - --- ========================= --- MEDEVAC System --- ========================= --- #region MEDEVAC - --- Initialize MEDEVAC system (called from CTLD:New) -function CTLD:InitMEDEVAC() - if not (CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled) then return end - - -- Initialize salvage pools - if CTLD.MEDEVAC.Salvage and CTLD.MEDEVAC.Salvage.Enabled then - CTLD._salvagePoints[self.Side] = CTLD._salvagePoints[self.Side] or 0 - end - - -- Setup event handler for unit deaths - local handler = EVENTHANDLER:New() - handler:HandleEvent(EVENTS.Dead) - local selfref = self - - function handler:OnEventDead(eventData) - -- First check if this is an invulnerable MEDEVAC crew member that needs respawning - local unit = eventData.IniUnit - if unit then - local unitName = unit:GetName() - if unitName then - for crewGroupName, crewData in pairs(CTLD._medevacCrews) do - if unitName:find(crewGroupName, 1, true) then - local now = timer.getTime() - if crewData.invulnerable and now < crewData.invulnerableUntil then - _logVerbose(string.format('[MEDEVAC] Invulnerable crew member %s killed, respawning...', unitName)) - -- Respawn this crew member - local id = timer.scheduleFunction(function() - local grp = Group.getByName(crewGroupName) - if grp and grp:isExist() then - local cfg = CTLD.MEDEVAC - local crewUnitType = cfg.CrewUnitTypes[crewData.side] or ((crewData.side == coalition.side.BLUE) and 'Soldier M4' or 'Paratrooper RPG-16') - -- Use the stored country ID from the original spawn - local countryId = crewData.countryId or ((crewData.side == coalition.side.BLUE) and (country.id.USA or 2) or 18) - - -- Random position near spawn point - local angle = math.random() * 2 * math.pi - local radius = 3 + math.random() * 5 - local spawnX = crewData.position.x + math.cos(angle) * radius - local spawnZ = crewData.position.z + math.sin(angle) * radius - - local newUnitData = { - type = crewUnitType, - name = unitName..'_respawn', - x = spawnX, - y = spawnZ, - heading = math.random() * 2 * math.pi - } - - coalition.addGroup(crewData.side, Group.Category.GROUND, { - visible = false, - lateActivation = false, - tasks = {}, - task = 'Ground Nothing', - route = {}, - units = {newUnitData}, - name = unitName..'_respawn_grp', - country = countryId - }) - - _logVerbose(string.format('[MEDEVAC] Respawned invulnerable crew member %s', unitName)) - end - end, nil, timer.getTime() + 1) - _trackOneShotTimer(id) - return -- Don't process as normal death - end - end - end - end - - -- Next check if this death wiped out an active MEDEVAC crew group - -- Be defensive: unit may be a Moose wrapper or raw DCS unit, and - -- not all objects will expose a "GetGroup" method. - local dcsGroup = (unit and unit.GetGroup and unit:GetGroup()) or (unit and unit.getGroup and unit:getGroup()) or nil - if dcsGroup then - local gName = dcsGroup:GetName() - if gName and CTLD._medevacCrews[gName] then - local anyAlive = false - local units = dcsGroup:GetUnits() - if units then - for _, u in ipairs(units) do - if u and u:IsAlive() then - anyAlive = true - break - end - end - end - if not anyAlive then - selfref:_RemoveMEDEVACCrew(gName, 'killed') - return - end - end - end - end - - -- Normal death processing for vehicle spawning MEDEVAC crews - if not unit then - _logDebug('[MEDEVAC] OnEventDead: No unit in eventData') - return - end - - -- Get the underlying DCS unit to safely extract data - local dcsUnit = unit.DCSUnit or unit - if not dcsUnit then - _logDebug('[MEDEVAC] OnEventDead: No DCS unit') - return - end - - -- Extract coalition from event data if available, otherwise from unit - local unitCoalition = eventData.IniCoalition - if not unitCoalition and unit and unit.GetCoalition then - local success, result = pcall(function() return unit:GetCoalition() end) - if success then - unitCoalition = result - end - end - - if not unitCoalition then - _logDebug('[MEDEVAC] OnEventDead: Could not determine coalition') - return - end - - if unitCoalition ~= selfref.Side then - _logDebug(string.format('[MEDEVAC] OnEventDead: Wrong coalition (unit: %s, CTLD: %s)', tostring(unitCoalition), tostring(selfref.Side))) - return - end - - -- Extract category from event data if available - local unitCategory = eventData.IniCategory or (unit.GetCategory and unit:GetCategory()) - if not unitCategory then - _logDebug('[MEDEVAC] OnEventDead: Could not determine category') - return - end - - if unitCategory ~= Unit.Category.GROUND_UNIT then - _logDebug(string.format('[MEDEVAC] OnEventDead: Not a ground unit (category: %s)', tostring(unitCategory))) - return - end - - -- Extract unit type name - local unitType = eventData.IniTypeName or (unit.GetTypeName and unit:GetTypeName()) - if not unitType then - _logDebug('[MEDEVAC] OnEventDead: Could not determine unit type') - return - end - - _logVerbose(string.format('[MEDEVAC] OnEventDead: Ground unit destroyed - %s', unitType)) - - -- Check if this unit type is eligible for MEDEVAC - local catalogEntry = selfref:_FindCatalogEntryByUnitType(unitType) - - if catalogEntry and catalogEntry.MEDEVAC == true then - _logVerbose(string.format('[MEDEVAC] OnEventDead: %s is MEDEVAC eligible, spawning crew', unitType)) - -- Pass eventData instead of unit to get position/heading safely - selfref:_SpawnMEDEVACCrew(eventData, catalogEntry) - else - if catalogEntry then - _logDebug(string.format('[MEDEVAC] OnEventDead: %s found in catalog but MEDEVAC=%s', unitType, tostring(catalogEntry.MEDEVAC))) - else - _logDebug(string.format('[MEDEVAC] OnEventDead: %s not found in catalog', unitType)) - end - end - - -- Sling-Load Salvage: Check if we should spawn a salvage crate for the OPPOSING coalition - if selfref.Config.SlingLoadSalvage and selfref.Config.SlingLoadSalvage.Enabled then - -- Get unit position - local unitPos = nil - if eventData.initiator and eventData.initiator.getPoint then - local success, point = pcall(function() return eventData.initiator:getPoint() end) - if success and point then - unitPos = point - end - end - - if unitPos then - -- Determine enemy coalition (who can collect this salvage) - local enemySide = (selfref.Side == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE - selfref:_SpawnSlingLoadSalvageCrate(unitPos, unitType, enemySide, eventData) - else - _logDebug('[SlingLoadSalvage] Could not get unit position for salvage spawn') - end - end - end - - self.MEDEVACHandler = handler - - -- Add hit event handler to prevent damage to invulnerable crews - local hitHandler = EVENTHANDLER:New() - hitHandler:HandleEvent(EVENTS.Hit) - - function hitHandler:OnEventHit(eventData) - local unit = eventData.TgtUnit - if not unit then return end - - local unitName = unit:GetName() - if not unitName then return end - - -- Check if this unit belongs to an invulnerable MEDEVAC crew - for crewGroupName, crewData in pairs(CTLD._medevacCrews) do - if unitName:find(crewGroupName, 1, true) then - -- This unit is part of a MEDEVAC crew, check invulnerability - local now = timer.getTime() - if crewData.invulnerable and now < crewData.invulnerableUntil then - _logVerbose(string.format('[MEDEVAC] Unit %s is invulnerable, preventing damage', unitName)) - -- Can't directly prevent damage in DCS, but log it - -- Infantry is fragile anyway, so invulnerability is more of a "hope they survive" thing - return - end - end - end - end - - self.MEDEVACHitHandler = hitHandler - - -- Start crew timeout checker (runs every 30 seconds) - self.MEDEVACSched = SCHEDULER:New(nil, function() - local ok, err = pcall(function() selfref:_CheckMEDEVACTimeouts() end) - if not ok then _logError('MEDEVAC timeout scheduler error: '..tostring(err)) end - end, {}, 30, 30) - - -- Sling-load salvage is handled by adaptive background loop - if self.Config.SlingLoadSalvage and self.Config.SlingLoadSalvage.Enabled then - _logInfo('Sling-Load Salvage system initialized for coalition '..tostring(self.Side)) - end - - -- Initialize MASH zones from config - self:_InitMASHZones() - - _logInfo('MEDEVAC system initialized for coalition '..tostring(self.Side)) -end - --- Find catalog entry that spawns a given unit type -function CTLD:_FindCatalogEntryByUnitType(unitType) - local catalog = self.Config.CrateCatalog or {} - local catalogSize = 0 - for _ in pairs(catalog) do catalogSize = catalogSize + 1 end - - _logDebug(string.format('[MEDEVAC] Searching catalog for unit type: %s (catalog has %d entries)', unitType, catalogSize)) - - for key, def in pairs(catalog) do - -- Check if this catalog entry builds the unit type - if def.build then - -- Check global lookup table that maps build functions to unit types - if type(def.build) == 'function' and _CTLD_BUILD_UNIT_TYPES and _CTLD_BUILD_UNIT_TYPES[def.build] then - local buildUnitType = _CTLD_BUILD_UNIT_TYPES[def.build] - _logDebug(string.format('[MEDEVAC] Catalog entry %s has unitType=%s (from global lookup)', key, tostring(buildUnitType))) - if buildUnitType == unitType then - _logDebug(string.format('[MEDEVAC] Found catalog entry for %s via global lookup: key=%s', unitType, key)) - return def - end - end - - -- Fallback: Try to extract unit type from build function string (legacy compatibility) - local buildStr = tostring(def.build) - if buildStr:find(unitType, 1, true) then - _logDebug(string.format('[MEDEVAC] Found catalog entry for %s via string search: key=%s', unitType, key)) - return def - end - else - _logDebug(string.format('[MEDEVAC] Catalog entry %s has no build function', key)) - end - - -- Also check if catalog entry has a unitType field directly - if def.unitType and def.unitType == unitType then - _logDebug(string.format('[MEDEVAC] Found catalog entry for %s via def.unitType field: key=%s', unitType, key)) - return def - end - end - - _logDebug(string.format('[MEDEVAC] No catalog entry found for unit type: %s', unitType)) - return nil -end - --- Spawn MEDEVAC crew when vehicle destroyed -function CTLD:_SpawnMEDEVACCrew(eventData, catalogEntry) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - - -- Probability check: does the crew survive to request rescue? - -- Use coalition-specific survival chance - local survivalChance = 0.02 -- default fallback - if cfg.CrewSurvivalChance then - if type(cfg.CrewSurvivalChance) == 'table' then - -- Per-coalition config - survivalChance = cfg.CrewSurvivalChance[self.Side] or 0.02 - else - -- Legacy single value config (backward compatibility) - survivalChance = cfg.CrewSurvivalChance - end - end - - local roll = math.random() - if roll > survivalChance then - -- Crew did not survive - _logVerbose(string.format('[MEDEVAC] Crew did not survive (roll: %.4f > %.4f)', roll, survivalChance)) - return - end - _logVerbose(string.format('[MEDEVAC] Crew survived! (roll: %.4f <= %.4f) - will spawn in 5 minutes after battle clears', roll, survivalChance)) - - -- Extract data from eventData instead of calling methods on dead unit - local unit = eventData.IniUnit - local unitType = eventData.IniTypeName or (unit and unit.GetTypeName and unit:GetTypeName()) - local unitName = eventData.IniUnitName or (unit and unit.GetName and unit:GetName()) or 'Unknown' - - -- Get position - the unit is dead, so we need to get position from the DCS initiator object - local pos = nil - - -- Try the raw DCS initiator object (this should have the last known position) - if eventData.initiator then - _logVerbose('[MEDEVAC] Trying DCS initiator object') - local dcsUnit = eventData.initiator - if dcsUnit and dcsUnit.getPoint then - local success, point = pcall(function() return dcsUnit:getPoint() end) - if success and point then - pos = point - _logVerbose(string.format('[MEDEVAC] Got position from DCS initiator:getPoint(): %.0f, %.0f, %.0f', pos.x, pos.y, pos.z)) - end - end - if not pos and dcsUnit and dcsUnit.getPosition then - local success, position = pcall(function() return dcsUnit:getPosition() end) - if success and position and position.p then - pos = position.p - _logVerbose(string.format('[MEDEVAC] Got position from DCS initiator:getPosition().p: %.0f, %.0f, %.0f', pos.x, pos.y, pos.z)) - end - end - end - - -- Try IniDCSUnit - if not pos and eventData.IniDCSUnit then - _logVerbose('[MEDEVAC] Trying IniDCSUnit') - local dcsUnit = eventData.IniDCSUnit - if dcsUnit and dcsUnit.getPoint then - local success, point = pcall(function() return dcsUnit:getPoint() end) - if success and point then - pos = point - _logVerbose(string.format('[MEDEVAC] Got position from IniDCSUnit:getPoint(): %.0f, %.0f, %.0f', pos.x, pos.y, pos.z)) - end - end - end - - if not pos or not unitType then - _logVerbose(string.format('[MEDEVAC] Cannot spawn crew - missing position (pos=%s) or unit type (type=%s)', tostring(pos), tostring(unitType))) - return - end - - -- Get heading if possible - local heading = 0 - if unit and unit.GetHeading then - local success, result = pcall(function() return unit:GetHeading() end) - if success and result then - heading = result - end - end - - -- Determine crew size - local crewSize = catalogEntry.crewSize or cfg.CrewDefaultSize or 2 - - -- Determine salvage value - local salvageValue = catalogEntry.salvageValue - if not salvageValue then - salvageValue = catalogEntry.required or cfg.Salvage.DefaultValue or 1 - end - - -- Find nearest enemy to spawn crew toward them - local spawnPoint = { x = pos.x, z = pos.z } - local enemySide = (self.Side == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE - local nearestEnemy = self:_findNearestEnemyGround({ x = pos.x, z = pos.z }, 2000) -- 2km search - - if nearestEnemy and nearestEnemy.point then - -- Calculate direction toward enemy - local dx = nearestEnemy.point.x - pos.x - local dz = nearestEnemy.point.z - pos.z - local dist = math.sqrt(dx*dx + dz*dz) - if dist > 0 then - local dirX = dx / dist - local dirZ = dz / dist - local offset = cfg.CrewSpawnOffset or 15 - spawnPoint.x = pos.x + dirX * offset - spawnPoint.z = pos.z + dirZ * offset - end - else - -- No enemy found, spawn at random offset - local angle = math.random() * 2 * math.pi - local offset = cfg.CrewSpawnOffset or 15 - spawnPoint.x = pos.x + math.cos(angle) * offset - spawnPoint.z = pos.z + math.sin(angle) * offset - end - - -- Prepare spawn data but delay actual spawning by 5 minutes (300 seconds) - local spawnDelay = cfg.CrewSpawnDelay or 300 -- 5 minutes default - local selfref = self - - _logVerbose(string.format('[MEDEVAC] Crew will spawn in %d seconds after battle clears', spawnDelay)) - - local spawnTimerId = timer.scheduleFunction(function() - -- Now spawn the crew after battle has cleared - local crewGroupName = string.format('MEDEVAC_Crew_%s_%d', unitType, math.random(100000, 999999)) - local crewUnitType = catalogEntry.crewType or cfg.CrewUnitTypes[selfref.Side] or ((selfref.Side == coalition.side.BLUE) and 'Soldier M4' or 'Infantry AK') - - _logVerbose(string.format('[MEDEVAC] Coalition: %s, CrewUnitType selected: %s, catalogEntry.crewType=%s, cfg.CrewUnitTypes[side]=%s', - (selfref.Side == coalition.side.BLUE and 'BLUE' or 'RED'), - crewUnitType, - tostring(catalogEntry.crewType), - tostring(cfg.CrewUnitTypes and cfg.CrewUnitTypes[selfref.Side]) - )) - - -- Determine if crew gets a MANPADS - -- Use coalition-specific MANPADS spawn chance - local manPadChance = 0.1 -- default fallback - if cfg.ManPadSpawnChance then - if type(cfg.ManPadSpawnChance) == 'table' then - -- Per-coalition config - manPadChance = cfg.ManPadSpawnChance[selfref.Side] or 0.1 - else - -- Legacy single value config (backward compatibility) - manPadChance = cfg.ManPadSpawnChance - end - end - local spawnManPad = math.random() <= manPadChance - local manPadIndex = nil - if spawnManPad and crewSize > 1 then - manPadIndex = math.random(1, crewSize) -- Random crew member gets the MANPADS - _logVerbose(string.format('[MEDEVAC] Crew will include MANPADS (unit %d of %d)', manPadIndex, crewSize)) - end - - -- Get country ID from the destroyed unit instead of trying to map coalition to country - -- This is the same approach used by the Medevac_KHASHURI.lua script - local countryId = nil - if eventData.initiator and eventData.initiator.getCountry then - local success, result = pcall(function() return eventData.initiator:getCountry() end) - if success and result then - countryId = result - _logVerbose(string.format('[MEDEVAC] Got country ID %d from destroyed unit', countryId)) - end - end - - -- Fallback if we couldn't get it from the unit - if not countryId then - _logVerbose('[MEDEVAC] WARNING: Could not get country from dead unit, using fallback') - if selfref.Side == coalition.side.BLUE then - countryId = country.id.USA or 2 - else - countryId = country.id.CJTF_RED or 18 -- Use CJTF RED as fallback - end - end - - _logVerbose(string.format('[MEDEVAC] Spawning crew now - coalition=%s, countryId=%d, crewUnitType=%s', - (selfref.Side == coalition.side.BLUE and 'BLUE' or 'RED'), - countryId, - crewUnitType)) - - local groupData = { - visible = false, - lateActivation = false, - tasks = {}, - task = 'Ground Nothing', - route = {}, - units = {}, - name = crewGroupName - -- Country ID passed directly to coalition.addGroup(), not in groupData - } - - for i = 1, crewSize do - -- Randomize position within a small radius (3-8 meters) for natural scattered appearance - local angle = math.random() * 2 * math.pi - local radius = 3 + math.random() * 5 -- 3-8 meters from center - local offsetX = math.cos(angle) * radius - local offsetZ = math.sin(angle) * radius - - -- Determine unit type (MANPADS or regular crew) - local unitType = crewUnitType - if i == manPadIndex then - unitType = cfg.ManPadUnitTypes[selfref.Side] or crewUnitType - _logVerbose(string.format('[MEDEVAC] Unit %d assigned MANPADS type: %s', i, unitType)) - end - - table.insert(groupData.units, { - type = unitType, - name = string.format('%s_U%d', crewGroupName, i), - x = spawnPoint.x + offsetX, - y = spawnPoint.z + offsetZ, - heading = math.random() * 2 * math.pi -- Random heading for each unit - }) - end - - _logVerbose(string.format('[MEDEVAC] About to call coalition.addGroup with country=%d (coalition=%s)', - countryId, - (selfref.Side == coalition.side.BLUE and 'BLUE' or 'RED'))) - - -- CRITICAL: First parameter is COUNTRY ID, not coalition ID! - -- This matches Medevac_KHASHURI.lua line 500: coalition.addGroup(_deadUnit:getCountry(), ...) - local crewGroup = coalition.addGroup(countryId, Group.Category.GROUND, groupData) - - if not crewGroup then - _logVerbose('[MEDEVAC] Failed to spawn crew') - return - end - - -- Double-check what coalition the spawned group actually belongs to - local spawnedCoalition = crewGroup:getCoalition() - _logVerbose(string.format('[MEDEVAC] Crew group %s spawned successfully - actual coalition: %s (%d)', - crewGroupName, - (spawnedCoalition == coalition.side.BLUE and 'BLUE' or spawnedCoalition == coalition.side.RED and 'RED' or 'NEUTRAL'), - spawnedCoalition)) - - -- Set crew to hold position and defend themselves - local crewController = crewGroup:getController() - if crewController then - crewController:setOption(AI.Option.Ground.id.ROE, AI.Option.Ground.val.ROE.RETURN_FIRE) - crewController:setOption(AI.Option.Ground.id.ALARM_STATE, AI.Option.Ground.val.ALARM_STATE.RED) - - -- Make crew immortal and/or invisible during announcement delay to prevent early death - if cfg.CrewImmortalDuringDelay then - local setImmortal = { - id = 'SetImmortal', - params = { value = true } - } - Controller.setCommand(crewController, setImmortal) - _logVerbose('[MEDEVAC] Crew set to immortal during announcement delay') - end - - if cfg.CrewInvisibleDuringDelay then - local setInvisible = { - id = 'SetInvisible', - params = { value = true } - } - Controller.setCommand(crewController, setInvisible) - _logVerbose('[MEDEVAC] Crew set to invisible to AI during announcement delay') - end - end - - -- Track crew immediately (but don't make mission available yet) - -- Smoke will be popped AFTER the announcement delay when they actually call for help - local crewData = { - vehicleType = unitType, - side = selfref.Side, - countryId = countryId, -- Store country ID for respawning - spawnTime = timer.getTime(), - position = spawnPoint, - salvageValue = salvageValue, - originalHeading = heading, - requestTime = nil, -- Will be set after announcement delay - warningsSent = {}, - invulnerable = false, - invulnerableUntil = 0, - greetingSent = false - } - CTLD._medevacCrews[crewGroupName] = crewData - - -- Wait before announcing mission (verify crew survival) - local announceDelay = cfg.CrewAnnouncementDelay or 60 - _logVerbose(string.format('[MEDEVAC] Will announce mission in %d seconds if crew survives', announceDelay)) - - local announceTimerId = timer.scheduleFunction(function() - -- Check if crew still exists - local g = Group.getByName(crewGroupName) - if not g or not g:isExist() then - _logVerbose(string.format('[MEDEVAC] Crew %s died before announcement, mission cancelled', crewGroupName)) - CTLD._medevacCrews[crewGroupName] = nil - return - end - - -- Crew survived! Now announce to players and make mission available - _logVerbose(string.format('[MEDEVAC] Crew %s survived, announcing mission', crewGroupName)) - - -- Optionally remove immortality after announcement; visibility is controlled by KeepCrewInvisibleForLifetime - local crewController = g:getController() - if crewController then - -- Remove immortality unless config says to keep it - if cfg.CrewImmortalDuringDelay and not cfg.CrewImmortalAfterAnnounce then - local setMortal = { - id = 'SetImmortal', - params = { value = false } - } - Controller.setCommand(crewController, setMortal) - _logVerbose('[MEDEVAC] Crew immortality removed, now vulnerable') - elseif cfg.CrewImmortalAfterAnnounce then - _logVerbose('[MEDEVAC] Crew remains immortal after announcement (per config)') - end - end - - -- Pop smoke now that they're calling for help - if cfg.PopSmokeOnSpawn then - local smokeColor = (cfg.SmokeColor and cfg.SmokeColor[selfref.Side]) or trigger.smokeColor.Red - _spawnMEDEVACSmoke(spawnPoint, smokeColor, cfg) - _logVerbose(string.format('[MEDEVAC] Crew popped smoke after announcement (color: %d)', smokeColor)) - end - - local grid = selfref:_GetMGRSString(spawnPoint) - - -- Pick random request message - local requestMessages = cfg.RequestAirLiftMessages or { - "Stranded Crew: This is {vehicle} crew at {grid}. Need pickup ASAP! We have {salvage} salvage to collect." - } - local messageTemplate = requestMessages[math.random(1, #requestMessages)] - - -- Replace placeholders - local message = messageTemplate - message = message:gsub("{vehicle}", unitType) - message = message:gsub("{grid}", grid) - message = message:gsub("{crew_size}", tostring(crewSize)) - message = message:gsub("{salvage}", tostring(salvageValue)) - - _msgCoalition(selfref.Side, message, 25) - - -- Now crew is requesting pickup - CTLD._medevacCrews[crewGroupName].requestTime = timer.getTime() - - -- Create map marker - if cfg.MapMarkers and cfg.MapMarkers.Enabled then - local markerID = selfref:_CreateMEDEVACMarker(spawnPoint, unitType, crewSize, salvageValue, crewGroupName) - CTLD._medevacCrews[crewGroupName].markerID = markerID - end - - end, nil, timer.getTime() + announceDelay) - _trackOneShotTimer(announceTimerId) - - end, nil, timer.getTime() + spawnDelay) - _trackOneShotTimer(spawnTimerId) - -end - --- Create map marker for MEDEVAC crew -function CTLD:_CreateMEDEVACMarker(position, vehicleType, crewSize, salvageValue, crewGroupName) - local cfg = CTLD.MEDEVAC.MapMarkers - if not cfg or not cfg.Enabled then return nil end - - local grid = self:_GetMGRSString(position) - local text = string.format('%s: %s Crew (%d) - Salvage: %d - %s', - cfg.IconText or '🔴 MEDEVAC', - vehicleType, - crewSize, - salvageValue, - grid - ) - - local markerID = _nextMarkupId() - trigger.action.markToCoalition(markerID, text, {x = position.x, y = 0, z = position.z}, self.Side, true) - - return markerID -end - --- Get MGRS grid string for position -function CTLD:_GetMGRSString(position) - if not position then - return 'N/A' - end - local lat, lon = coord.LOtoLL({x = position.x, y = 0, z = position.z}) - local mgrs = coord.LLtoMGRS(lat, lon) - if mgrs and mgrs.UTMZone and mgrs.MGRSDigraph then - -- Ensure Easting and Northing are numbers - local easting = tonumber(mgrs.Easting) or 0 - local northing = tonumber(mgrs.Northing) or 0 - return string.format('%s%s %05d %05d', mgrs.UTMZone, mgrs.MGRSDigraph, easting, northing) - end - return string.format('%.0f, %.0f', position.x, position.z) -end - --- Check for MEDEVAC crew timeouts and send warnings -function CTLD:_CheckMEDEVACTimeouts() - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - - local now = timer.getTime() - local toRemove = {} - - for crewGroupName, data in pairs(CTLD._medevacCrews) do - if data.side == self.Side then - local requestTime = data.requestTime - if requestTime then -- Only check after crew has requested pickup - local elapsed = now - requestTime - if type(data.warningsSent) ~= 'table' then - data.warningsSent = {} - end - local remaining = (cfg.CrewTimeout or 3600) - elapsed - - -- Check for approaching rescue helos (pop smoke and send greeting with cooldown) - if cfg.PopSmokeOnApproach then - local approachDist = cfg.PopSmokeOnApproachDistance or 5000 - local crewPos = data.position - local smokeCooldown = cfg.SmokeCooldown or 900 -- Default 15 minutes (900 seconds) - local lastSmoke = data.lastSmokeTime or 0 - local canPopSmoke = (now - lastSmoke) >= smokeCooldown - - if canPopSmoke then - -- Check all units of this coalition for nearby transport helos - local coalitionUnits = coalition.getGroups(self.Side, Group.Category.AIRPLANE) - local heloGroups = coalition.getGroups(self.Side, Group.Category.HELICOPTER) - - if heloGroups then - for _, grp in ipairs(heloGroups) do - if grp and grp:isExist() then - local units = grp:getUnits() - if units then - for _, unit in ipairs(units) do - if unit and unit:isExist() and unit:isActive() then - -- Check if this is a transport helo (in AllowedAircraft list) - local unitType = unit:getTypeName() - local isTransport = false - if self.Config.AllowedAircraft then - for _, allowed in ipairs(self.Config.AllowedAircraft) do - if unitType == allowed then - isTransport = true - break - end - end - end - - if isTransport then - local unitPos = unit:getPoint() - if unitPos and crewPos then - local dx = unitPos.x - crewPos.x - local dz = unitPos.z - crewPos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist <= approachDist then - -- Rescue helo detected! Pop smoke and send greeting - local smokeColor = (cfg.SmokeColor and cfg.SmokeColor[self.Side]) or trigger.smokeColor.Red - _spawnMEDEVACSmoke(crewPos, smokeColor, cfg) - - -- Pick random greeting message - local greetings = cfg.GreetingMessages or {"We see you! Over here!"} - local greeting = greetings[math.random(1, #greetings)] - - _msgCoalition(self.Side, string.format('[MEDEVAC] %s crew: "%s"', data.vehicleType, greeting), 10) - - -- Set cooldown timer instead of permanent flag - data.lastSmokeTime = now - local cooldownMins = smokeCooldown / 60 - _logVerbose(string.format('[MEDEVAC] Crew %s detected helo at %.0fm, popped smoke (cooldown: %.0f mins)', - crewGroupName, dist, cooldownMins)) - break - end - end - end - end - end - end - end - if data.lastSmokeTime == now then break end -- Just popped smoke, exit loop - end - end -- if heloGroups then - end -- if canPopSmoke then - end -- if cfg.PopSmokeOnApproach then - - -- Send warnings - if cfg.Warnings then - for _, warning in ipairs(cfg.Warnings) do - local warnTime = warning.time - if remaining <= warnTime and not data.warningsSent[warnTime] then - local grid = self:_GetMGRSString(data.position) - _msgCoalition(self.Side, _fmtTemplate(warning.message, { - crew = data.vehicleType..' crew', - grid = grid - }), 15) - data.warningsSent[warnTime] = true - end - end - end - - -- Check timeout - if remaining <= 0 then - table.insert(toRemove, crewGroupName) - end - end - end - end - - -- Remove timed-out crews - for _, crewGroupName in ipairs(toRemove) do - self:_RemoveMEDEVACCrew(crewGroupName, 'timeout') - end -end - --- Remove MEDEVAC crew (timeout or death) -function CTLD:_RemoveMEDEVACCrew(crewGroupName, reason) - local data = CTLD._medevacCrews[crewGroupName] - if not data then return end - - -- Remove map marker - if data.markerID then - pcall(function() trigger.action.removeMark(data.markerID) end) - end - - -- Destroy crew group - local g = Group.getByName(crewGroupName) - if g and g:isExist() then - g:destroy() - end - - -- Send message - if reason == 'timeout' then - local grid = self:_GetMGRSString(data.position) - _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_crew_timeout, { - vehicle = data.vehicleType, - grid = grid - }), 15) - - -- Track statistics - if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CTLD._medevacStats[self.Side].timedOut = (CTLD._medevacStats[self.Side].timedOut or 0) + 1 - end - elseif reason == 'killed' then - local grid = self:_GetMGRSString(data.position) - local lines = CTLD.Messages.medevac_crew_killed_lines - if lines and #lines > 0 then - local line = lines[math.random(1, #lines)] - _msgCoalition(self.Side, _fmtTemplate(line, { - vehicle = data.vehicleType, - grid = grid, - }), 15) - else - _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_crew_killed, { - vehicle = data.vehicleType, - grid = grid, - }), 15) - end - - -- Track statistics - if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CTLD._medevacStats[self.Side].killed = (CTLD._medevacStats[self.Side].killed or 0) + 1 - end - end - - -- Remove from tracking - CTLD._medevacCrews[crewGroupName] = nil - - if data.rescueGroup and CTLD._medevacEnrouteStates then - CTLD._medevacEnrouteStates[data.rescueGroup] = nil - end - - _logVerbose(string.format('[MEDEVAC] Removed crew %s (reason: %s)', crewGroupName, reason or 'unknown')) -end - --- Check if crew was picked up (called from troop loading system) -function CTLD:CheckMEDEVACPickup(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local pos = unit:GetPointVec3() - local searchRadius = 100 -- meters to search for nearby crew - - for crewGroupName, data in pairs(CTLD._medevacCrews) do - if data.side == self.Side and data.requestTime then - local crewPos = data.position - local dx = pos.x - crewPos.x - local dz = pos.z - crewPos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist <= searchRadius then - -- Check if crew group still exists and is being loaded - local crewGroup = Group.getByName(crewGroupName) - if crewGroup and crewGroup:isExist() then - -- Crew was picked up! Handle respawn - self:_HandleMEDEVACPickup(group, crewGroupName, data) - return true - end - end - end - end - - return false -end - --- Auto-pickup: Send MEDEVAC crews to landed helicopter within range -function CTLD:AutoPickupMEDEVACCrew(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - if not cfg.AutoPickup or not cfg.AutoPickup.Enabled then return end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - -- Only work with landed helicopters - if _isUnitInAir(unit) then return end - - local autoCfg = cfg.AutoPickup - local requireGround = (autoCfg.RequireGroundContact ~= false) - if requireGround then - local agl = _getUnitAGL(unit) - if agl > (autoCfg.GroundContactAGL or 3) then - return -- still hovering/high skid - wait for full touchdown - end - local gs = _getGroundSpeed(unit) - if gs > (autoCfg.MaxLandingSpeed or 2) then - return -- helicopter is sliding/taxiing - hold crews until stable - end - end - - local pos = unit:GetPointVec3() - if not pos then return end - local maxDist = autoCfg.MaxDistance or 200 - local groupName = group:GetName() - - -- Skip if helicopter already has an active load hold - if CTLD._medevacLoadStates and CTLD._medevacLoadStates[groupName] then - return - end - - -- Find nearby MEDEVAC crews within pickup range - for crewGroupName, data in pairs(CTLD._medevacCrews) do - if data.side == self.Side and data.requestTime and not data.pickedUp then - local crewPos = data.position - local dx = pos.x - crewPos.x - local dz = pos.z - crewPos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist <= maxDist then - local crewGroup = Group.getByName(crewGroupName) - if crewGroup and crewGroup:isExist() then - -- Crew is close enough - start load hold - local loadCfg = cfg.AutoPickup or {} - local delay = loadCfg.LoadDelay or 15 - local now = timer.getTime() - - -- Check if already in a load hold - local existingState = CTLD._medevacLoadStates[groupName] - if not existingState then - -- Start new load hold - CTLD._medevacLoadStates[groupName] = { - startTime = now, - delay = delay, - crewGroupName = crewGroupName, - crewData = data, - holdAnnounced = true, - nextReminder = now + math.max(1.5, delay / 3), - lastQualified = now, - } - - _msgGroup(group, string.format("MEDEVAC crew from %s is boarding. Hold position for %d seconds...", - data.vehicleType or 'unknown vehicle', delay), 10) - _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold started for %s (delay=%.1fs, crew=%s, dist=%.0fm)', - groupName, delay, crewGroupName, dist)) - end - end - end - end - end -end --- Auto-unload: Send MEDEVAC crews to landed helicopter within MASH zone --- Scan all active transport groups for auto-pickup and auto-unload opportunities -function CTLD:ScanMEDEVACAutoActions() - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - - -- Progress any ongoing load and unload holds before new scans - self:_UpdateMedevacLoadStates() - self:_UpdateMedevacUnloadStates() - - -- Scan all active transport groups - for gname, _ in pairs(self.MenusByGroup or {}) do - local group = GROUP:FindByName(gname) - if group and group:IsAlive() then - local unit = group:GetUnit(1) - if unit and unit:IsAlive() then - local isAirborne = _isUnitInAir(unit) - - local autoUnloadCfg = cfg.AutoUnload or {} - local aglLimit = autoUnloadCfg.GroundContactAGL or 2 - local agl = _getUnitAGL(unit) - if agl == nil then agl = aglLimit end - local hasGroundContact = (not isAirborne) - or (agl <= aglLimit) - - if not isAirborne then - -- Helicopter is landed according to DCS state - if cfg.AutoPickup and cfg.AutoPickup.Enabled then - self:AutoPickupMEDEVACCrew(group) - end - end - - if cfg.AutoUnload and cfg.AutoUnload.Enabled and hasGroundContact then - -- Reduce log spam: only attempt auto-unload when there are rescued crews onboard - local crews = self:_CollectRescuedCrewsForGroup(group:GetName()) - if crews and #crews > 0 then - self:AutoUnloadMEDEVACCrew(group) - end - end - - self:_TickMedevacEnrouteMessage(group, unit, isAirborne) - else - CTLD._medevacEnrouteStates[gname] = nil - end - else - CTLD._medevacEnrouteStates[gname] = nil - end - end - - -- Finalize unload checks after handling current landings - self:_UpdateMedevacUnloadStates() - - local enrouteStates = CTLD._medevacEnrouteStates - if enrouteStates then - for gname, _ in pairs(enrouteStates) do - if not (self.MenusByGroup and self.MenusByGroup[gname]) then - local group = GROUP:FindByName(gname) - if not group or not group:IsAlive() then - enrouteStates[gname] = nil - end - end - end - end -end - --- Auto-unload: Automatically unload MEDEVAC crews when landed in MASH zone -function CTLD:AutoUnloadMEDEVACCrew(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - if not cfg.AutoUnload or not cfg.AutoUnload.Enabled then return end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - local gname = group:GetName() or 'UNKNOWN' - - -- Early silent exit (reduces log spam): only proceed if there are rescued crews onboard - local earlyCrews = self:_CollectRescuedCrewsForGroup(gname) - if not earlyCrews or #earlyCrews == 0 then return end - - local autoCfg = cfg.AutoUnload or {} - local aglLimit = autoCfg.GroundContactAGL or 2.0 - local gsLimit = autoCfg.MaxLandingSpeed or 2.0 - local settleLimit = autoCfg.SettledAGL or (aglLimit + 2.0) - - local agl = _getUnitAGL(unit) - if agl == nil then agl = 0 end - local gs = _getGroundSpeed(unit) - if gs == nil then gs = 0 end - local inAir = _isUnitInAir(unit) - - -- Treat the helicopter as landed when weight-on-wheels flips or when the skid height is within tolerance. - local hasGroundContact = (not inAir) or (agl <= aglLimit) - if not hasGroundContact then - _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: no ground contact (agl=%.2f, limit=%.2f, inAir=%s)', gname, agl, aglLimit, tostring(inAir))) - return - end - - if inAir and agl > aglLimit then - _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: AGL %.2f above limit %.2f while still airborne', gname, agl, aglLimit)) - return - end - - if gs > gsLimit then - _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: ground speed %.2f above limit %.2f', gname, gs, gsLimit)) - return - end - - if settleLimit and settleLimit > 0 and agl > settleLimit then - _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: AGL %.2f above settled limit %.2f', gname, agl, settleLimit)) - return - end - - local crews = self:_CollectRescuedCrewsForGroup(group:GetName()) - if #crews == 0 then return end - - -- Check if inside MASH zone - local pos = unit:GetPointVec3() - local inMASH, mashZone = self:_IsPositionInMASHZone({ x = pos.x, z = pos.z }) - if not inMASH then - _logDebug(string.format('[MEDEVAC][AutoUnload] %s skipped: not inside MASH zone (crews=%d)', gname, #crews)) - return - end - - _logVerbose(string.format('[MEDEVAC][AutoUnload] %s qualified for unload in MASH %s (crews=%d, agl=%.2f, gs=%.2f)', - gname, - tostring((mashZone and (mashZone.name or mashZone.unitName)) or 'UNKNOWN'), - #crews, - agl, - gs)) - - -- Begin or maintain the unload hold state - self:_EnsureMedevacUnloadState(group, mashZone, crews, { trigger = 'auto' }) -end - --- Gather all MEDEVAC crews currently onboard the specified rescue group -function CTLD:_CollectRescuedCrewsForGroup(groupName) - local crews = {} - if not groupName then return crews end - - for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do - if data.side == self.Side and data.pickedUp and data.rescueGroup == groupName then - crews[#crews + 1] = { name = crewGroupName, data = data } - end - end - - return crews -end - --- Periodically deliver enroute status chatter while MEDEVAC patients are onboard -function CTLD:_TickMedevacEnrouteMessage(group, unit, isAirborne, forceSend) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - - local enrouteCfg = cfg.EnrouteMessages or {} - if enrouteCfg.Enabled == false then return end - - if not group or not unit or not unit:IsAlive() then - if group then - local gname = group:GetName() - if gname and gname ~= '' then - CTLD._medevacEnrouteStates[gname] = nil - end - end - return - end - - local gname = group:GetName() - if not gname or gname == '' then return end - - local crews = self:_CollectRescuedCrewsForGroup(gname) - if not crews or #crews == 0 then - CTLD._medevacEnrouteStates[gname] = nil - return - end - - if not isAirborne and not forceSend then - return - end - - local interval = enrouteCfg.Interval or 180 - if interval <= 0 then interval = 180 end - - CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} - local now = timer.getTime() - local state = CTLD._medevacEnrouteStates[gname] - - if not state then - state = { nextSend = now + interval, lastIndex = nil } - CTLD._medevacEnrouteStates[gname] = state - end - - if not forceSend and now < (state.nextSend or 0) then - return - end - - local vector = self:_ComputeNearestMASHVector(unit) - if not vector then return end - - local messages = cfg.EnrouteToMashMessages or {} - if #messages == 0 then return end - - local idx = math.random(1, #messages) - if state.lastIndex and #messages > 1 and idx == state.lastIndex then - idx = (idx % #messages) + 1 - end - state.lastIndex = idx - state.nextSend = now + interval - - local text = _fmtTemplate(messages[idx], { - mash = vector.name, - brg = vector.bearing, - rng = vector.rangeValue, - rng_u = vector.rangeUnit - }) - - _msgGroup(group, text, math.min(self.Config.MessageDuration or 15, 18)) -end - --- Ensure an unload hold state exists for the group and announce if newly started -function CTLD:_EnsureMedevacUnloadState(group, mashZone, crews, opts) - CTLD._medevacUnloadStates = CTLD._medevacUnloadStates or {} - - if not group or not group:IsAlive() then return nil end - - local gname = group:GetName() - local now = timer.getTime() - local cfg = self.MEDEVAC or {} - local cfgAuto = cfg.AutoUnload or {} - local delay = cfgAuto.UnloadDelay or 2 - if delay < 0 then delay = 0 end - - local state = CTLD._medevacUnloadStates[gname] - if not state then - state = { - groupName = gname, - side = self.Side, - startTime = now, - delay = delay, - holdAnnounced = false, - mashZoneName = mashZone and (mashZone.name or mashZone.unitName) or nil, - triggeredBy = opts and opts.trigger or 'auto', - } - CTLD._medevacUnloadStates[gname] = state - self:_AnnounceMedevacUnloadHold(group, state) - _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold started for %s (delay=%0.1fs, trigger=%s, mash=%s, crews=%d)', - gname, - state.delay, - tostring(state.triggeredBy), - tostring(state.mashZoneName or 'UNKNOWN'), - crews and #crews or 0)) - else - state.delay = delay - state.triggeredBy = opts and opts.trigger or state.triggeredBy - if mashZone then - state.mashZoneName = mashZone.name or mashZone.unitName or state.mashZoneName - end - _logDebug(string.format('[MEDEVAC][AutoUnload] Hold refreshed for %s (trigger=%s, crews=%d)', - gname, - tostring(state.triggeredBy), - crews and #crews or 0)) - end - - state.lastQualified = now - state.pendingCrewCount = crews and #crews or state.pendingCrewCount - - return state -end - --- Notify the pilot that unloading is in progress and set up reminder cadence -function CTLD:_AnnounceMedevacUnloadHold(group, state) - if not group or not state or state.holdAnnounced then return end - - state.holdAnnounced = true - local delay = math.ceil(state.delay or 0) - if delay < 1 then delay = 1 end - - _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_unload_hold, { - seconds = delay - }), math.min(delay + 2, 12)) - - local unloadMsgs = (self.MEDEVAC and self.MEDEVAC.UnloadingMessages) or {} - if #unloadMsgs > 0 then - local msg = unloadMsgs[math.random(1, #unloadMsgs)] - _msgGroup(group, msg, math.min(delay, 10)) - end - - local now = timer.getTime() - local spacing = state.delay or 2 - spacing = math.max(1.5, math.min(4, spacing / 2)) - state.nextReminder = now + spacing -end - --- Send a reminder from the unloading message pool while waiting out the hold -function CTLD:_SendMedevacUnloadReminder(group) - if not group then return end - local unloadMsgs = (self.MEDEVAC and self.MEDEVAC.UnloadingMessages) or {} - if #unloadMsgs == 0 then return end - - local msg = unloadMsgs[math.random(1, #unloadMsgs)] - _msgGroup(group, msg, 6) -end - --- Inform the pilot that the unload was cancelled and the hold must restart -function CTLD:_NotifyMedevacUnloadAbort(group, state, reasonKey) - if not group or not state or state.abortNotified or not state.holdAnnounced then return end - - local reasonText - if reasonKey == 'air' then - reasonText = 'wheels up too soon' - elseif reasonKey == 'zone' then - reasonText = 'left the MASH zone' - elseif reasonKey == 'agl' then - reasonText = 'climbed above unload height' - elseif reasonKey == 'crew' then - reasonText = 'no MEDEVAC patients onboard' - else - reasonText = 'hold interrupted' - end - - local delay = math.ceil(state.delay or 0) - if delay < 1 then delay = 1 end - - _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_unload_aborted, { - reason = reasonText, - seconds = delay - }), 10) - - state.abortNotified = true -end - --- Finalize the unload, deliver all crews, and celebrate success -function CTLD:_CompleteMedevacUnload(group, crews) - if not group or not group:IsAlive() then return end - if not crews or #crews == 0 then return end - - for _, crew in ipairs(crews) do - self:_DeliverMEDEVACCrewToMASH(group, crew.name, crew.data) - end - - local successMsgs = (self.MEDEVAC and self.MEDEVAC.UnloadCompleteMessages) or {} - if #successMsgs > 0 then - local msg = successMsgs[math.random(1, #successMsgs)] - _msgGroup(group, msg, 10) - end - - _logVerbose(string.format('[MEDEVAC] Auto unload complete for %s (%d crew group(s) delivered)', group:GetName(), #crews)) -end - --- Send loading reminder message to pilot -function CTLD:_SendMedevacLoadReminder(group) - if not group then return end - local loadingMsgs = (self.MEDEVAC and self.MEDEVAC.LoadingMessages) or {} - if #loadingMsgs == 0 then return end - - local msg = loadingMsgs[math.random(1, #loadingMsgs)] - _msgGroup(group, msg, 6) -end - --- Inform the pilot that the loading was cancelled and the hold must restart -function CTLD:_NotifyMedevacLoadAbort(group, state, reasonKey) - if not group or not state or state.abortNotified or not state.holdAnnounced then return end - - local reasonText - if reasonKey == 'air' then - reasonText = 'wheels up too soon' - elseif reasonKey == 'agl' then - reasonText = 'climbed above loading height' - elseif reasonKey == 'crew' then - reasonText = 'crew lost contact' - else - reasonText = 'hold interrupted' - end - - local delay = math.ceil(state.delay or 0) - if delay < 1 then delay = 1 end - - _msgGroup(group, string.format("MEDEVAC boarding aborted: %s. Land and hold for %d seconds to restart.", - reasonText, delay), 10) - - state.abortNotified = true -end - --- Complete the load, pick up crew, and show success message -function CTLD:_CompleteMedevacLoad(group, crewGroupName, crewData) - if not group or not group:IsAlive() then return end - if not crewGroupName or not crewData then return end - - -- Destroy the crew unit - local crewGroup = Group.getByName(crewGroupName) - if crewGroup and crewGroup:isExist() then - crewGroup:destroy() - end - - -- Handle the actual pickup (respawn vehicle, etc.) - self:_HandleMEDEVACPickup(group, crewGroupName, crewData) - - -- Show completion message - local successMsgs = (self.MEDEVAC and self.MEDEVAC.LoadMessages) or {} - if #successMsgs > 0 then - local msg = successMsgs[math.random(1, #successMsgs)] - _msgGroup(group, msg, 10) - end - - _logVerbose(string.format('[MEDEVAC] Auto load complete for %s (crew %s)', group:GetName(), crewGroupName)) -end - --- Maintain load hold states, handling completion or interruption -function CTLD:_UpdateMedevacLoadStates() - local states = CTLD._medevacLoadStates - if not states or not next(states) then return end - - local now = timer.getTime() - local cfg = self.MEDEVAC or {} - local cfgAuto = cfg.AutoPickup or {} - local aglLimit = cfgAuto.GroundContactAGL or 3 - local settleLimit = cfgAuto.SettledAGL or 6 - local gsLimit = cfgAuto.MaxLandingSpeed or 2 - local airGrace = cfgAuto.AirAbortGrace or 2 - - for gname, state in pairs(states) do - local group = GROUP:FindByName(gname) - if not group or not group:IsAlive() then - states[gname] = nil - _logDebug(string.format('[MEDEVAC][AutoLoad] %s removed: group not alive', gname)) - else - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then - states[gname] = nil - _logDebug(string.format('[MEDEVAC][AutoLoad] %s removed: unit not alive', gname)) - else - local removeState = false - local agl = _getUnitAGL(unit) - local gs = _getGroundSpeed(unit) - - -- Check if crew still exists - local crewGroup = Group.getByName(state.crewGroupName) - if not crewGroup or not crewGroup:isExist() then - _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold abort for %s: crew %s no longer exists', gname, state.crewGroupName)) - removeState = true - else - -- Check distance to crew - local crewUnit = crewGroup:getUnit(1) - if crewUnit then - local crewPos = crewUnit:getPoint() - local heliPos = unit:GetPointVec3() - local dx = heliPos.x - crewPos.x - local dz = heliPos.z - crewPos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist > 40 then - self:_NotifyMedevacLoadAbort(group, state, 'crew') - _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold abort for %s: moved too far from crew (%.1fm)', gname, dist)) - removeState = true - end - end - - if not removeState then - -- Check landing status (similar to unload logic) - local landed = not _isUnitInAir(unit) - if landed then - if settleLimit and settleLimit > 0 and agl > settleLimit then - landed = false - state.highAglSince = state.highAglSince or now - _logDebug(string.format('[MEDEVAC][AutoLoad] %s hold paused: AGL %.2f above settled limit %.2f', gname, agl, settleLimit)) - else - state.highAglSince = nil - end - else - state.highAglSince = nil - if agl <= aglLimit and gs <= gsLimit then - landed = true - end - end - - if landed then - state.airborneSince = nil - state.lastQualified = now - - -- Send reminders while holding - if state.nextReminder and now >= state.nextReminder then - self:_SendMedevacLoadReminder(group) - local spacing = state.delay or 2 - spacing = math.max(1.5, math.min(4, spacing / 2)) - state.nextReminder = now + spacing - end - - -- Complete load after delay - if (now - state.startTime) >= state.delay then - self:_CompleteMedevacLoad(group, state.crewGroupName, state.crewData) - _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold complete for %s', gname)) - removeState = true - end - else - state.airborneSince = state.airborneSince or now - if (now - state.airborneSince) >= airGrace then - self:_NotifyMedevacLoadAbort(group, state, 'air') - _logVerbose(string.format('[MEDEVAC][AutoLoad] Hold abort for %s: airborne for %.1fs (grace=%.1f)', - gname, - now - state.airborneSince, - airGrace)) - removeState = true - end - end - end - end - - if removeState then - states[gname] = nil - end - end - end - end -end - --- Maintain unload hold states, handling completion or interruption -function CTLD:_UpdateMedevacUnloadStates() - local states = CTLD._medevacUnloadStates - if not states or not next(states) then return end - - local now = timer.getTime() - local cfg = self.MEDEVAC or {} - local cfgAuto = cfg.AutoUnload or {} - local aglLimit = cfgAuto.GroundContactAGL or 2 - local gsLimit = cfgAuto.MaxLandingSpeed or 2 - local airGrace = cfgAuto.AirAbortGrace or 2 - - for gname, state in pairs(states) do - -- Multiple CTLD instances share the global unload state table; skip entries owned by the other coalition. - if not state.side or state.side == self.Side then - local group = GROUP:FindByName(gname) - local removeState = false - - if not group or not group:IsAlive() then - removeState = true - else - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then - removeState = true - else - local crews = self:_CollectRescuedCrewsForGroup(gname) - if #crews == 0 then - self:_NotifyMedevacUnloadAbort(group, state, 'crew') - _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold abort for %s: crew list empty', gname)) - removeState = true - else - local agl = _getUnitAGL(unit) - if agl == nil then agl = 0 end - local gs = _getGroundSpeed(unit) - if gs == nil then gs = 0 end - local settleLimit = cfgAuto.SettledAGL or (aglLimit + 2.0) - - local landed = not _isUnitInAir(unit) - if landed then - if settleLimit and settleLimit > 0 and agl > settleLimit then - landed = false - state.highAglSince = state.highAglSince or now - _logDebug(string.format('[MEDEVAC][AutoUnload] %s hold paused: AGL %.2f above settled limit %.2f', gname, agl, settleLimit)) - else - state.highAglSince = nil - end - else - state.highAglSince = nil - if agl <= aglLimit and gs <= gsLimit then - landed = true - end - end - - if landed then - state.airborneSince = nil - state.lastQualified = now - local pos = unit:GetPointVec3() - local inMASH, mashZone = self:_IsPositionInMASHZone({ x = pos.x, z = pos.z }) - if not inMASH then - self:_NotifyMedevacUnloadAbort(group, state, 'zone') - _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold abort for %s: left MASH zone', gname)) - removeState = true - else - state.mashZoneName = mashZone and (mashZone.name or mashZone.unitName or state.mashZoneName) - - -- Send reminders while holding - if state.nextReminder and now >= state.nextReminder then - self:_SendMedevacUnloadReminder(group) - local spacing = state.delay or 2 - spacing = math.max(1.5, math.min(4, spacing / 2)) - state.nextReminder = now + spacing - end - - -- Complete unload after delay - if (now - state.startTime) >= state.delay then - self:_CompleteMedevacUnload(group, crews) - _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold complete for %s (crews delivered=%d)', gname, #crews)) - removeState = true - end - end - else - state.airborneSince = state.airborneSince or now - if (now - state.airborneSince) >= airGrace then - self:_NotifyMedevacUnloadAbort(group, state, 'air') - _logVerbose(string.format('[MEDEVAC][AutoUnload] Hold abort for %s: airborne for %.1fs (grace=%.1f)', - gname, - now - state.airborneSince, - airGrace)) - removeState = true - end - end - end - end - end - - if removeState then - states[gname] = nil - end - end - end -end - --- Handle MEDEVAC crew pickup - respawn vehicle -function CTLD:_HandleMEDEVACPickup(rescueGroup, crewGroupName, crewData) - local cfg = CTLD.MEDEVAC - - -- Remove map marker - if crewData.markerID then - pcall(function() trigger.action.removeMark(crewData.markerID) end) - end - - -- Show initial load message (random from LoadMessages) - local loadMsgs = cfg.LoadMessages or {} - if #loadMsgs > 0 then - local randomLoadMsg = loadMsgs[math.random(1, #loadMsgs)] - _msgGroup(rescueGroup, randomLoadMsg, 5) - end - - -- Show loading progress messages during a brief delay (simulate boarding time) - local loadingDuration = 8 -- seconds for crew to board - local loadingMsgInterval = 2 -- show message every 2 seconds - local loadingMsgs = cfg.LoadingMessages or {} - local gname = rescueGroup:GetName() - - if #loadingMsgs > 0 then - local messageCount = math.floor(loadingDuration / loadingMsgInterval) - for i = 1, messageCount do - local msgId = timer.scheduleFunction(function() - local g = GROUP:FindByName(gname) - if g and g:IsAlive() then - local randomLoadingMsg = loadingMsgs[math.random(1, #loadingMsgs)] - _msgGroup(g, randomLoadingMsg, loadingMsgInterval - 0.5) - end - end, nil, timer.getTime() + (i * loadingMsgInterval)) - _trackOneShotTimer(msgId) - end - end - - -- Schedule final completion after loading duration - local completionId = timer.scheduleFunction(function() - local g = GROUP:FindByName(gname) - if g and g:IsAlive() then - -- Show completion message - _msgGroup(g, _fmtTemplate(CTLD.Messages.medevac_crew_loaded, { - vehicle = crewData.vehicleType, - crew_size = crewData.crewSize - }), 10) - - local unit = g:GetUnit(1) - if unit and unit:IsAlive() then - self:_TickMedevacEnrouteMessage(g, unit, _isUnitInAir(unit), true) - end - end - - -- Track statistics - if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CTLD._medevacStats[self.Side].rescued = (CTLD._medevacStats[self.Side].rescued or 0) + 1 - end - - -- Respawn vehicle if enabled - if cfg.RespawnOnPickup then - local respawnId = timer.scheduleFunction(function() - self:_RespawnMEDEVACVehicle(crewData) - end, nil, timer.getTime() + 2) -- 2 second delay for realism - _trackOneShotTimer(respawnId) - end - - -- Mark crew as picked up (for MASH delivery tracking) - crewData.pickedUp = true - crewData.rescueGroup = gname - - _logVerbose(string.format('[MEDEVAC] Crew %s picked up by %s', crewGroupName, gname)) - end, nil, timer.getTime() + loadingDuration) - _trackOneShotTimer(completionId) -end - --- Respawn the vehicle at original death location -function CTLD:_RespawnMEDEVACVehicle(crewData) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.RespawnOnPickup then return end - - -- Calculate respawn position (offset from original death) - local offset = cfg.RespawnOffset or 15 - local angle = math.random() * 2 * math.pi - local respawnPos = { - x = crewData.position.x + math.cos(angle) * offset, - z = crewData.position.z + math.sin(angle) * offset - } - - local heading = cfg.RespawnSameHeading and (crewData.originalHeading or 0) or 0 - - -- Find catalog entry to get build function - local catalogEntry = nil - local catalogKey = nil - for key, def in pairs(self.Config.CrateCatalog or {}) do - if def and def.MEDEVAC then - local matches = false - - if def.unitType and def.unitType == crewData.vehicleType then - matches = true - end - - if (not matches) and type(def.unitTypes) == 'table' then - for _, unitType in ipairs(def.unitTypes) do - if unitType == crewData.vehicleType then - matches = true - break - end - end - end - - if not matches then - local ok, unitTypes = pcall(function() - return self:_collectEntryUnitTypes(def) - end) - if ok and type(unitTypes) == 'table' then - for _, unitType in ipairs(unitTypes) do - if unitType == crewData.vehicleType then - matches = true - break - end - end - end - end - - if matches then - catalogEntry = def - catalogKey = key - break - end - end - end - - if not catalogEntry or not catalogEntry.build then - _logVerbose('[MEDEVAC] No catalog entry found for respawn: '..crewData.vehicleType) - return - end - - -- Spawn vehicle using catalog build function - local groupData = catalogEntry.build(respawnPos, math.deg(heading)) - if not groupData then - _logVerbose('[MEDEVAC] Failed to generate group data for: '..crewData.vehicleType) - return - end - - if crewData.countryId then - groupData.country = crewData.countryId - end - - local category = catalogEntry.category or Group.Category.GROUND - - local newGroup = coalition.addGroup(self.Side, category, groupData) - - if newGroup then - if catalogKey then - _logVerbose(string.format('[MEDEVAC] Respawn using catalog entry %s for %s', tostring(catalogKey), crewData.vehicleType)) - end - _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_vehicle_respawned, { - vehicle = crewData.vehicleType - }), 10) - - -- Track statistics - if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CTLD._medevacStats[self.Side].vehiclesRespawned = (CTLD._medevacStats[self.Side].vehiclesRespawned or 0) + 1 - end - - _logVerbose(string.format('[MEDEVAC] Respawned %s at %.0f, %.0f', crewData.vehicleType, respawnPos.x, respawnPos.z)) - else - _logVerbose('[MEDEVAC] Failed to respawn vehicle: '..crewData.vehicleType) - end -end - --- Check if troops being unloaded are MEDEVAC crew and if inside MASH zone -function CTLD:CheckMEDEVACDelivery(group, troopData) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return false end - if not cfg.Salvage or not cfg.Salvage.Enabled then return false end - if not group or not group:IsAlive() then return false end - - local gname = group:GetName() - local crews = self:_CollectRescuedCrewsForGroup(gname) - if #crews == 0 then return false end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return false end - - if _isUnitInAir(unit) then - local delay = (cfg.AutoUnload and cfg.AutoUnload.UnloadDelay) or 2 - delay = math.max(1, math.ceil(delay or 0)) - _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_unload_hold, { - seconds = delay - }), 10) - return 'pending' - end - - local pos = unit:GetPointVec3() - local inMASH, mashZone = self:_IsPositionInMASHZone({ x = pos.x, z = pos.z }) - if not inMASH then return false end - - self:_EnsureMedevacUnloadState(group, mashZone, crews, { trigger = 'manual' }) - self:_UpdateMedevacUnloadStates() - - local remaining = self:_CollectRescuedCrewsForGroup(gname) - if #remaining == 0 then - return 'delivered' - end - - return 'pending' -end - --- Deliver MEDEVAC crew to MASH - award salvage points -function CTLD:_DeliverMEDEVACCrewToMASH(group, crewGroupName, crewData) - local cfg = CTLD.MEDEVAC.Salvage - if not cfg or not cfg.Enabled then return end - - -- Award salvage points - CTLD._salvagePoints[self.Side] = (CTLD._salvagePoints[self.Side] or 0) + crewData.salvageValue - - -- Message to coalition (shown after brief delay to let unload message be seen) - local msgId = timer.scheduleFunction(function() - _msgCoalition(self.Side, _fmtTemplate(CTLD.Messages.medevac_crew_delivered_mash, { - player = _playerNameFromGroup(group), - vehicle = crewData.vehicleType, - salvage = crewData.salvageValue, - total = CTLD._salvagePoints[self.Side] - }), 15) - end, nil, timer.getTime() + 3) - _trackOneShotTimer(msgId) - - -- Track statistics - if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CTLD._medevacStats[self.Side].delivered = (CTLD._medevacStats[self.Side].delivered or 0) + 1 - CTLD._medevacStats[self.Side].salvageEarned = (CTLD._medevacStats[self.Side].salvageEarned or 0) + crewData.salvageValue - end - - -- Remove map marker - if crewData.markerID then - pcall(function() trigger.action.removeMark(crewData.markerID) end) - end - - -- Destroy crew group to prevent clutter - local crewGroup = Group.getByName(crewGroupName) - if crewGroup and crewGroup:isExist() then - crewGroup:destroy() - end - - -- Remove crew from tracking - CTLD._medevacCrews[crewGroupName] = nil - - if group and group:IsAlive() then - local gname = group:GetName() - if gname and gname ~= '' then - CTLD._medevacEnrouteStates = CTLD._medevacEnrouteStates or {} - CTLD._medevacEnrouteStates[gname] = nil - end - end - - _logVerbose(string.format('[MEDEVAC] Delivered %s crew to MASH - awarded %d salvage (total: %d)', - crewData.vehicleType, crewData.salvageValue, CTLD._salvagePoints[self.Side])) -end - --- Try to use salvage to spawn a crate when out of stock -function CTLD:_TryUseSalvageForCrate(group, crateKey, catalogEntry) - local cfg = CTLD.MEDEVAC and CTLD.MEDEVAC.Salvage - if not cfg or not cfg.Enabled then return false end - if not cfg.AutoApply then return false end - - -- Check if item has salvage value (use same fallback logic as MEDEVAC) - local salvageCost = catalogEntry.salvageValue - if not salvageCost then - salvageCost = catalogEntry.required or cfg.DefaultValue or 1 - end - if salvageCost <= 0 then return false end - - -- Check if we have enough salvage - local available = CTLD._salvagePoints[self.Side] or 0 - if available < salvageCost then - -- Send insufficient salvage message - local deficit = salvageCost - available - _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_salvage_insufficient, { - need = salvageCost, - deficit = deficit - })) - return false - end - - -- Consume salvage - CTLD._salvagePoints[self.Side] = available - salvageCost - - -- Track statistics - if CTLD.MEDEVAC and CTLD.MEDEVAC.Statistics and CTLD.MEDEVAC.Statistics.Enabled then - CTLD._medevacStats[self.Side].salvageUsed = (CTLD._medevacStats[self.Side].salvageUsed or 0) + salvageCost - end - - -- Send success message - _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_salvage_used, { - item = self:_friendlyNameForKey(crateKey), - salvage = salvageCost, - remaining = CTLD._salvagePoints[self.Side] - })) - - _logVerbose(string.format('[Salvage] Used %d salvage for %s (remaining: %d)', - salvageCost, crateKey, CTLD._salvagePoints[self.Side])) - - return true -end - --- Check if salvage can cover a crate request (for bundle pre-checks) -function CTLD:_CanUseSalvageForCrate(crateKey, catalogEntry, quantity) - local cfg = CTLD.MEDEVAC and CTLD.MEDEVAC.Salvage - if not cfg or not cfg.Enabled then return false end - if not cfg.AutoApply then return false end - - quantity = quantity or 1 - -- Check if item has salvage value (use same fallback logic as MEDEVAC) - local salvageCost = catalogEntry.salvageValue - if not salvageCost then - salvageCost = catalogEntry.required or cfg.DefaultValue or 1 - end - salvageCost = salvageCost * quantity - if salvageCost <= 0 then return false end - - local available = CTLD._salvagePoints[self.Side] or 0 - return available >= salvageCost -end - --- Resolve the 2D position of a MASH zone, handling fixed and mobile variants -function CTLD:_ResolveMASHPosition(mashData, mashKey) - if not mashData then return nil end - - if mashData.position and mashData.position.x and mashData.position.z then - return { x = mashData.position.x, z = mashData.position.z } - end - - local zone = mashData.zone - if zone then - if zone.GetPointVec3 then - local ok, vec3 = pcall(function() return zone:GetPointVec3() end) - if ok and vec3 then - return { x = vec3.x, z = vec3.z } - end - end - if zone.GetPointVec2 then - local ok, vec2 = pcall(function() return zone:GetPointVec2() end) - if ok and vec2 then - return { x = vec2.x, z = vec2.y } - end - end - if zone.GetCoordinate then - local ok, coord = pcall(function() return zone:GetCoordinate() end) - if ok and coord then - local vec3 = coord.GetVec3 and coord:GetVec3() - if vec3 then - return { x = vec3.x, z = vec3.z } - end - end - end - end - - if mashKey and trigger and trigger.misc and trigger.misc.getZone then - local ok, zoneInfo = pcall(function() return trigger.misc.getZone(mashKey) end) - if ok and zoneInfo and zoneInfo.point then - return { x = zoneInfo.point.x, z = zoneInfo.point.z } - end - end - - return nil -end - --- Find the nearest friendly MASH zone to a given point (x/z expected) -function CTLD:_FindNearestMASHForPoint(point) - if not point then return nil end - - local nearestName, nearestData, nearestPos - local nearestDist = math.huge - - for name, data in pairs(CTLD._mashZones or {}) do - if data.side == self.Side then - local pos = self:_ResolveMASHPosition(data, name) - if pos then - local dx = pos.x - point.x - local dz = pos.z - point.z - local dist = math.sqrt(dx * dx + dz * dz) - if dist < nearestDist then - nearestDist = dist - nearestName = name - nearestData = data - nearestPos = pos - end - end - end - end - - if not nearestData or not nearestPos then - return nil - end - - local displayName = nearestData.displayName or nearestData.catalogKey - if not displayName then - local zone = nearestData.zone - if zone and zone.GetName then - local ok, zname = pcall(function() return zone:GetName() end) - if ok and zname then - displayName = zname - end - end - end - displayName = displayName or nearestName or 'MASH' - - return { - name = displayName, - position = nearestPos, - distance = nearestDist, - data = nearestData, - } -end - --- Build directional info toward the nearest MASH for a specific unit -function CTLD:_ComputeNearestMASHVector(unit) - if not unit or not unit:IsAlive() then return nil end - local pos = unit:GetPointVec3() - if not pos then return nil end - - local info = self:_FindNearestMASHForPoint({ x = pos.x, z = pos.z }) - if not info or not info.position then return nil end - - local bearing = _bearingDeg({ x = pos.x, z = pos.z }, info.position) - local isMetric = _getPlayerIsMetric(unit) - local rangeValue, rangeUnit = _fmtRange(info.distance, isMetric) - - if rangeUnit == 'm' and rangeValue >= 1000 then - rangeValue = _round(rangeValue / 1000, 1) - rangeUnit = 'km' - end - - local valueText - if math.abs(rangeValue - math.floor(rangeValue)) < 0.05 then - valueText = string.format('%d', math.floor(rangeValue + 0.5)) - else - valueText = string.format('%.1f', rangeValue) - end - - return { - name = info.name, - bearing = bearing, - rangeValue = valueText, - rangeUnit = rangeUnit, - } -end - --- Check if position is inside any MASH zone -function CTLD:_IsPositionInMASHZone(position) - for zoneName, mashData in pairs(CTLD._mashZones) do - if mashData.side == self.Side then - local zonePos = self:_ResolveMASHPosition(mashData, zoneName) - if zonePos then - local radius = mashData.radius or CTLD.MEDEVAC.MASHZoneRadius or 500 - local dx = position.x - zonePos.x - local dz = position.z - zonePos.z - local dist = math.sqrt(dx*dx + dz*dz) - - if dist <= radius then - return true, mashData - end - end - end - end - return false, nil -end - --- Initialize MASH zones from config -function CTLD:_InitMASHZones() - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then return end - - _logDebug('_InitMASHZones called for coalition '..tostring(self.Side)) - _logDebug('self.MASHZones count: '..tostring(#(self.MASHZones or {}))) - _logDebug('self.Config.Zones.MASHZones count: '..tostring(#(self.Config.Zones and self.Config.Zones.MASHZones or {}))) - - -- Fixed MASH zones are now initialized via InitZones() in the standard Zones structure - -- This function now focuses on setting up mobile MASH tracking and announcements - - if not CTLD._mashZones then CTLD._mashZones = {} end - - -- Register fixed MASH zones in the global _mashZones table for delivery detection - -- (mobile MASH zones will be added dynamically when built) - for _, mz in ipairs(self.MASHZones or {}) do - local name = mz:GetName() - local zdef = self._ZoneDefs.MASHZones[name] - CTLD._mashZones[name] = { - zone = mz, - side = self.Side, - isMobile = false, - radius = (zdef and zdef.radius) or cfg.MASHZoneRadius or 500, - freq = (zdef and zdef.freq) or nil - } - _logVerbose('[MEDEVAC] Registered fixed MASH zone: '..name) - end -end - --- ========================= --- MEDEVAC Menu Functions --- ========================= - --- List all active MEDEVAC requests -function CTLD:ListActiveMEDEVACRequests(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - local count = 0 - local lines = {} - table.insert(lines, '=== Active MEDEVAC Requests ===') - table.insert(lines, '') - - for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do - if data.side == self.Side and data.requestTime then - count = count + 1 - local grid = self:_GetMGRSString(data.position) - local elapsed = timer.getTime() - data.requestTime - local remaining = (cfg.CrewTimeout or 3600) - elapsed - local remainMin = math.floor(remaining / 60) - - table.insert(lines, string.format('%d. %s crew', count, data.vehicleType)) - table.insert(lines, string.format(' Grid: %s', grid)) - table.insert(lines, string.format(' Crew Size: %d', data.crewSize or 2)) - table.insert(lines, string.format(' Salvage: %d points', data.salvageValue or 1)) - table.insert(lines, string.format(' Time Remaining: %d minutes', remainMin)) - table.insert(lines, '') - end - end - - if count == 0 then - table.insert(lines, 'No active MEDEVAC requests.') - table.insert(lines, '') - table.insert(lines, 'MEDEVAC missions appear when friendly vehicles') - table.insert(lines, 'are destroyed and crew survives to call for rescue.') - end - - _msgGroup(group, table.concat(lines, '\n'), 30) -end - --- Show nearest MEDEVAC location -function CTLD:NearestMEDEVACLocation(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - local unit = group:GetUnit(1) - if not unit then return end - - local pos = unit:GetCoordinate() - if not pos then return end - - local nearest = nil - local nearestDist = math.huge - - for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do - if data.side == self.Side and data.requestTime then - local dist = math.sqrt((data.position.x - pos.x)^2 + (data.position.z - pos.z)^2) - if dist < nearestDist then - nearestDist = dist - nearest = data - end - end - end - - if not nearest then - _msgGroup(group, 'No active MEDEVAC requests.') - return - end - - local grid = self:_GetMGRSString(nearest.position) - local distKm = nearestDist / 1000 - local distNm = nearestDist / 1852 - local elapsed = timer.getTime() - nearest.requestTime - local remaining = (cfg.CrewTimeout or 3600) - elapsed - local remainMin = math.floor(remaining / 60) - - local lines = {} - table.insert(lines, '=== Nearest MEDEVAC ===') - table.insert(lines, '') - table.insert(lines, string.format('%s crew at %s', nearest.vehicleType, grid)) - table.insert(lines, string.format('Distance: %.1f km / %.1f nm', distKm, distNm)) - table.insert(lines, string.format('Crew Size: %d', nearest.crewSize or 2)) - table.insert(lines, string.format('Salvage Value: %d points', nearest.salvageValue or 1)) - table.insert(lines, string.format('Time Remaining: %d minutes', remainMin)) - - _msgGroup(group, table.concat(lines, '\n'), 20) -end - --- Show coalition salvage points -function CTLD:ShowSalvagePoints(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - local salvage = CTLD._salvagePoints[self.Side] or 0 - - local lines = {} - table.insert(lines, '=== Coalition Salvage Points ===') - table.insert(lines, '') - table.insert(lines, string.format('Current Balance: %d points', salvage)) - table.insert(lines, '') - table.insert(lines, 'Earn salvage by:') - table.insert(lines, '- Rescuing MEDEVAC crews and delivering them to a MASH zone') - table.insert(lines, '') - table.insert(lines, 'Use salvage to:') - table.insert(lines, '- Build items that are out of stock (automatic)') - table.insert(lines, '- Cost = item\'s required crate count') - - _msgGroup(group, table.concat(lines, '\n'), 20) -end - -function CTLD:ShowOnboardManifest(group) - if not group then return end - - local gname = group:GetName() - if not gname or gname == '' then return end - - self:_refreshLoadedTroopSummaryForGroup(gname) - - local lines = { '=== Onboard Manifest ===', '' } - local hasCargo = false - - local crateData = CTLD._loadedCrates[gname] - if crateData and crateData.byKey then - local keys = {} - for crateKey, count in pairs(crateData.byKey) do - if (count or 0) > 0 then - table.insert(keys, crateKey) - end - end - table.sort(keys, function(a, b) - return self:_lookupCrateLabel(a) < self:_lookupCrateLabel(b) - end) - for _, crateKey in ipairs(keys) do - local count = crateData.byKey[crateKey] or 0 - if count > 0 then - table.insert(lines, string.format('Crate: %s x %d', self:_lookupCrateLabel(crateKey), count)) - hasCargo = true - end - end - end - - local troopSummary = CTLD._loadedTroopTypes[gname] - if troopSummary and troopSummary.total and troopSummary.total > 0 then - local typeKeys = {} - for typeKey, _ in pairs(troopSummary.byType) do - if (troopSummary.byType[typeKey] or 0) > 0 then - table.insert(typeKeys, typeKey) - end - end - table.sort(typeKeys, function(a, b) - local la = troopSummary.labels and troopSummary.labels[a] or self:_lookupTroopLabel(a) - local lb = troopSummary.labels and troopSummary.labels[b] or self:_lookupTroopLabel(b) - return la < lb - end) - for _, typeKey in ipairs(typeKeys) do - local count = troopSummary.byType[typeKey] or 0 - if count > 0 then - local label = troopSummary.labels and troopSummary.labels[typeKey] or self:_lookupTroopLabel(typeKey) - table.insert(lines, string.format('Troop: %s x %d', label, count)) - hasCargo = true - end - end - end - - local crews = self:_CollectRescuedCrewsForGroup(gname) - if crews and #crews > 0 then - local crewTotals = {} - for _, crew in ipairs(crews) do - local data = crew.data or {} - local label = data.vehicleType or 'Wounded crew' - local size = data.crewSize or 0 - if size <= 0 then size = 1 end - crewTotals[label] = (crewTotals[label] or 0) + size - end - local labels = {} - for label, _ in pairs(crewTotals) do - table.insert(labels, label) - end - table.sort(labels) - for _, label in ipairs(labels) do - table.insert(lines, string.format('Wounded: %s x %d', label, crewTotals[label])) - end - hasCargo = true - end - - if not hasCargo then - table.insert(lines, 'Nothing onboard.') - end - - local salvage = CTLD._salvagePoints and (CTLD._salvagePoints[self.Side] or 0) or 0 - table.insert(lines, '') - table.insert(lines, string.format('Salvage: %d pts', salvage)) - - _msgGroup(group, table.concat(lines, '\n'), math.min(self.Config.MessageDuration or 20, 25)) -end - --- Vectors to nearest MEDEVAC (shows top 3 with time remaining) -function CTLD:VectorsToNearestMEDEVAC(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - local unit = group:GetUnit(1) - if not unit then return end - - local pos = unit:GetCoordinate() - if not pos then return end - - local heading = unit:GetHeading() or 0 - local isMetric = _getPlayerIsMetric(unit) - - -- Collect all active MEDEVAC requests with distance - local requests = {} - for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do - if data.side == self.Side and data.requestTime and not data.pickedUp then - local dist = math.sqrt((data.position.x - pos.x)^2 + (data.position.z - pos.z)^2) - table.insert(requests, { - data = data, - distance = dist - }) - end - end - - if #requests == 0 then - _msgGroup(group, 'No active MEDEVAC requests.') - return - end - - -- Sort by distance (closest first) - table.sort(requests, function(a, b) return a.distance < b.distance end) - - -- Show top 3 (or fewer if less than 3 exist) - local lines = {} - table.insert(lines, 'MEDEVAC VECTORS (nearest 3):') - table.insert(lines, '') - - local maxShow = math.min(3, #requests) - for i = 1, maxShow do - local req = requests[i] - local data = req.data - local dist = req.distance - - local dx = data.position.x - pos.x - local dz = data.position.z - pos.z - local bearing = math.deg(math.atan2(dz, dx)) - if bearing < 0 then bearing = bearing + 360 end - - local relativeBrg = bearing - heading - if relativeBrg < 0 then relativeBrg = relativeBrg + 360 end - if relativeBrg > 180 then relativeBrg = relativeBrg - 360 end - - -- Calculate time remaining - local timeoutAt = data.spawnTime + (cfg.CrewTimeout or 3600) - local timeRemainSec = math.max(0, timeoutAt - timer.getTime()) - local timeRemainMin = math.floor(timeRemainSec / 60) - - -- Format distance - local distV, distU = _fmtRange(dist, isMetric) - - -- Build message for this crew - table.insert(lines, string.format('#%d: %s crew', i, data.vehicleType)) - table.insert(lines, string.format(' BRG %03d° (%+.0f° rel) | RNG %s %s', - math.floor(bearing + 0.5), relativeBrg, distV, distU)) - table.insert(lines, string.format(' Time left: %d min | Salvage: %d pts', - timeRemainMin, data.salvageValue or 1)) - - if i < maxShow then - table.insert(lines, '') - end - end - - _msgGroup(group, table.concat(lines, '\n'), 20) -end - --- List MASH locations -function CTLD:ListMASHLocations(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - local unit = group:GetUnit(1) - local playerPos = unit and unit:GetCoordinate() - local playerVec3 = nil - if playerPos then - if playerPos.GetVec3 then - local ok, vec = pcall(function() return playerPos:GetVec3() end) - if ok then playerVec3 = vec end - elseif playerPos.x and playerPos.z then - playerVec3 = playerPos - end - end - - local count = 0 - local lines = {} - table.insert(lines, '=== MASH Locations ===') - table.insert(lines, '') - - for name, data in pairs(CTLD._mashZones or {}) do - if data.side == self.Side then - count = count + 1 - - -- Get position from zone object - local position = nil - if data.position then - position = data.position - elseif data.zone and data.zone.GetCoordinate then - local coord = data.zone:GetCoordinate() - if coord then - position = {x = coord.x, z = coord.z} - end - end - - local grid = position and self:_GetMGRSString(position) or 'Unknown' - local typeStr = data.isMobile and 'Mobile' or 'Fixed' - local radius = tonumber(data.radius) or 500 - - local label = data.displayName or name - table.insert(lines, string.format('%d. MASH %s (%s)', count, label, typeStr)) - table.insert(lines, string.format(' Grid: %s', grid)) - table.insert(lines, string.format(' Radius: %d m', radius)) - - if playerVec3 and position then - local dist = math.sqrt((position.x - playerVec3.x)^2 + (position.z - playerVec3.z)^2) - local distKm = dist / 1000 - table.insert(lines, string.format(' Distance: %.1f km', distKm)) - end - - if data.freq then - local freq = tonumber(data.freq) - if freq then - table.insert(lines, string.format(' Beacon: %.2f MHz', freq)) - else - table.insert(lines, string.format(' Beacon: %s', tostring(data.freq))) - end - end - - table.insert(lines, '') - end - end - - if count == 0 then - table.insert(lines, 'No MASH zones configured.') - table.insert(lines, '') - table.insert(lines, 'MASH zones are where you deliver rescued') - table.insert(lines, 'MEDEVAC crews to earn salvage points.') - else - table.insert(lines, 'Deliver rescued crews to any MASH to earn salvage.') - end - - _msgGroup(group, table.concat(lines, '\n'), 30) -end - --- Pop smoke at all active MEDEVAC sites -function CTLD:PopSmokeAtMEDEVACSites(group) - _logVerbose('[MEDEVAC] PopSmokeAtMEDEVACSites called') - - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _logVerbose('[MEDEVAC] MEDEVAC system not enabled') - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - if not CTLD._medevacCrews then - _logVerbose('[MEDEVAC] No _medevacCrews table') - _msgGroup(group, 'No active MEDEVAC requests to mark with smoke.') - return - end - - local count = 0 - _logVerbose(string.format('[MEDEVAC] Checking %d crew entries', CTLD._medevacCrews and table.getn(CTLD._medevacCrews) or 0)) - - for crewGroupName, data in pairs(CTLD._medevacCrews) do - if data and data.side == self.Side and data.requestTime and data.position then - count = count + 1 - _logVerbose(string.format('[MEDEVAC] Popping smoke for crew %s', crewGroupName)) - - local smokeColor = (cfg.SmokeColor and cfg.SmokeColor[self.Side]) or trigger.smokeColor.Red - _spawnMEDEVACSmoke(data.position, smokeColor, cfg) - end - end - - _logVerbose(string.format('[MEDEVAC] Popped smoke at %d locations', count)) - - if count == 0 then - _msgGroup(group, 'No active MEDEVAC requests to mark with smoke.') - else - _msgGroup(group, string.format('Smoke popped at %d MEDEVAC location(s).', count), 10) - end -end - --- Pop smoke at MASH zones (delivery locations) -function CTLD:PopSmokeAtMASHZones(group) - _logVerbose('[MEDEVAC] PopSmokeAtMASHZones called') - - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _logVerbose('[MEDEVAC] MEDEVAC system not enabled') - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - if not CTLD._mashZones then - _msgGroup(group, 'No MASH zones configured.') - return - end - - local count = 0 - local smokeColor = trigger.smokeColor.Orange - - for name, data in pairs(CTLD._mashZones) do - if data and data.side == self.Side then - -- Get position from zone object - local position = nil - if data.position then - position = data.position - elseif data.zone and data.zone.GetCoordinate then - local coord = data.zone:GetCoordinate() - if coord then - position = {x = coord.x, z = coord.z} - end - end - - if position then - count = count + 1 - _spawnMEDEVACSmoke(position, smokeColor, cfg) - _logVerbose(string.format('[MEDEVAC] Popped smoke at MASH zone: %s', name)) - end - end - end - - if count == 0 then - _msgGroup(group, 'No MASH zones found for your coalition.') - else - _msgGroup(group, string.format('Smoke popped at %d MASH zone(s).', count), 10) - end -end - --- Clear all MEDEVAC missions (admin function) -function CTLD:ClearAllMEDEVACMissions(group) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _msgGroup(group, 'MEDEVAC system is not enabled.') - return - end - - local count = 0 - - for crewGroupName, data in pairs(CTLD._medevacCrews or {}) do - if data.side == self.Side then - count = count + 1 - self:_RemoveMEDEVACCrew(crewGroupName, 'admin_clear') - end - end - - _msgGroup(group, string.format('Cleared %d MEDEVAC mission(s).', count), 10) - _logVerbose(string.format('[MEDEVAC] Admin cleared %d MEDEVAC missions for coalition %s', count, self.Side)) -end - --- #endregion MEDEVAC - --- #region Mobile MASH - --- Create a Mobile MASH zone and start announcements -function CTLD:_CreateMobileMASH(group, position, catalogDef) - local cfg = CTLD.MEDEVAC - if not cfg or not cfg.Enabled then - _logDebug('[MobileMASH] Config missing or MEDEVAC disabled; aborting mobile deployment') - return - end - if not cfg.MobileMASH or not cfg.MobileMASH.Enabled then - _logDebug('[MobileMASH] MobileMASH feature disabled in config; aborting') - return - end - - if not position or not position.x or not position.z then - _logError('[MobileMASH] Missing build position; aborting Mobile MASH deployment') - return - end - - local groupNamePreview = 'unknown' - if group then - local okPreview, namePreview = pcall(function() return group:getName() end) - if okPreview and namePreview and namePreview ~= '' then groupNamePreview = namePreview end - end - _logVerbose(string.format('[MobileMASH] Build requested for group %s at (%.1f, %.1f)', groupNamePreview, position.x or 0, position.z or 0)) - - local function safeGetName(g) - if not g then return nil end - if g.getName then - local ok, name = pcall(function() return g:getName() end) - if ok and name and name ~= '' then return name end - end - if g.GetName then - local ok, name = pcall(function() return g:GetName() end) - if ok and name and name ~= '' then return name end - end - return nil - end - - local side = catalogDef.side or self.Side - if not side then - _logError('[MobileMASH] Unable to determine coalition side; aborting Mobile MASH deployment') - return - end - _logDebug(string.format('[MobileMASH] Using coalition side %s (%s)', tostring(side), tostring(catalogDef.side or self.Side))) - - CTLD._mobileMASHCounter = CTLD._mobileMASHCounter or { [coalition.side.BLUE] = 0, [coalition.side.RED] = 0 } - CTLD._mobileMASHCounter[side] = (CTLD._mobileMASHCounter[side] or 0) + 1 - local index = CTLD._mobileMASHCounter[side] - _logDebug(string.format('[MobileMASH] Assigned deployment index %d for side %s', index, tostring(side))) - - local mashId = string.format('MOBILE_MASH_%d_%d', side, index) - local displayName - if cfg.MobileMASH.AutoIncrementName == false then - displayName = catalogDef.description or mashId - else - displayName = string.format('Mobile MASH %d', index) - end - _logDebug(string.format('[MobileMASH] mashId=%s displayName=%s recipeDesc=%s', mashId, tostring(displayName), tostring(catalogDef.description))) - - local initialPos = { x = position.x, z = position.z } - local radius = cfg.MobileMASH.ZoneRadius or 500 - local beaconFreq = cfg.MobileMASH.BeaconFrequency or '30.0 FM' - local mashGroupName = safeGetName(group) - _logDebug(string.format('[MobileMASH] Initial position (%.1f, %.1f) radius %.1f freq %s groupName=%s', initialPos.x or 0, initialPos.z or 0, radius, tostring(beaconFreq), tostring(mashGroupName))) - - local function buildZoneObject(name, r, pos) - if ZONE_RADIUS and VECTOR2 and VECTOR2.New then - local ok, zoneObj = pcall(function() - local v2 = VECTOR2:New(pos.x, pos.z) - return ZONE_RADIUS:New(name, v2, r) - end) - if ok and zoneObj then - _logDebug('[MobileMASH] Created ZONE_RADIUS object for mobile MASH') - return zoneObj - end - if not ok then - _logDebug(string.format('[MobileMASH] ZONE_RADIUS creation failed: %s', tostring(zoneObj))) - end - end - local posCopy = { x = pos.x, z = pos.z } - _logDebug('[MobileMASH] Falling back to table-based zone representation') - local zoneObj = {} - function zoneObj:GetName() - return name - end - function zoneObj:GetPointVec3() - return { x = posCopy.x, y = 0, z = posCopy.z } - end - function zoneObj:GetRadius() - return r - end - function zoneObj:SetPointVec3(vec3) - if vec3 and vec3.x and vec3.z then - posCopy.x = vec3.x - posCopy.z = vec3.z - end - end - function zoneObj:SetVec2(vec2) - if vec2 and vec2.x and vec2.y then - posCopy.x = vec2.x - posCopy.z = vec2.y - end - end - return zoneObj - end - - local rawGroupHandle = group - - local function finalizeMobileMASH() - _logVerbose(string.format('[MobileMASH] Finalizing Mobile MASH %s', mashId)) - local mashGroupMoose = nil - if GROUP and GROUP.FindByName and not mashGroupName then - local ok, found = pcall(function() - -- coalition.addGroup sometimes renames groups; scan by coalition - if rawGroupHandle and rawGroupHandle.getName then - return GROUP:FindByName(rawGroupHandle:getName()) - end - return nil - end) - if ok and found then - mashGroupMoose = found - mashGroupName = mashGroupName or safeGetName(found) - end - elseif GROUP and GROUP.FindByName and mashGroupName then - local ok, found = pcall(function() return GROUP:FindByName(mashGroupName) end) - if ok and found then mashGroupMoose = found end - end - - local function resolveRawGroup() - if rawGroupHandle and rawGroupHandle.isExist and rawGroupHandle:isExist() then - return rawGroupHandle - end - if mashGroupName and Group and Group.getByName then - local ok, g = pcall(function() return Group.getByName(mashGroupName) end) - if ok and g then - rawGroupHandle = g - if g.isExist and g:isExist() then - return rawGroupHandle - end - elseif not ok then - _logDebug(string.format('[MobileMASH] resolveRawGroup Group.getByName error: %s', tostring(g))) - end - end - return nil - end - - local function groupIsAlive() - if mashGroupMoose and mashGroupMoose.IsAlive then - local ok, alive = pcall(function() return mashGroupMoose:IsAlive() end) - if ok and alive then return true end - if not ok then - _logDebug(string.format('[MobileMASH] groupIsAlive Moose check error: %s', tostring(alive))) - end - end - local g = resolveRawGroup() - if not g then return false end - local units = g:getUnits() - if not units then return false end - for _, u in ipairs(units) do - if u and u.isExist and u:isExist() then - return true - end - end - return false - end - - local function groupVec3() - if mashGroupMoose and mashGroupMoose.GetCoordinate then - local ok, coord = pcall(function() return mashGroupMoose:GetCoordinate() end) - if ok and coord then - local vec3 = coord.GetVec3 and coord:GetVec3() - if vec3 then return vec3 end - end - if not ok then - _logDebug(string.format('[MobileMASH] groupVec3 Moose coordinate error: %s', tostring(coord))) - end - end - local g = resolveRawGroup() - if g then - local units = g:getUnits() - if units and units[1] and units[1].getPoint then - local ok, point = pcall(function() return units[1]:getPoint() end) - if ok and point then return point end - end - end - return nil - end - - local zoneObj = buildZoneObject(displayName, radius, initialPos) - CTLD._mashZones = CTLD._mashZones or {} - - local mashData = { - id = mashId, - displayName = displayName, - position = { x = initialPos.x, z = initialPos.z }, - radius = radius, - side = side, - group = mashGroupMoose or rawGroupHandle, - groupName = mashGroupName, - isMobile = true, - catalogKey = catalogDef.description or 'Mobile MASH', - zone = zoneObj, - freq = beaconFreq, - } - - CTLD._mashZones[mashId] = mashData - _logDebug(string.format('[MobileMASH] Registered mashId=%s displayName=%s zoneRadius=%.1f freq=%s', mashId, displayName, radius, tostring(beaconFreq))) - - self._ZoneDefs = self._ZoneDefs or { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {} } - self._ZoneDefs.MASHZones = self._ZoneDefs.MASHZones or {} - self._ZoneDefs.MASHZones[displayName] = { name = displayName, radius = radius, active = true, freq = beaconFreq } - - self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } - self._ZoneActive.MASH = self._ZoneActive.MASH or {} - self._ZoneActive.MASH[displayName] = true - - -- Add zone to MASHZones array so it's recognized by the system - self.MASHZones = self.MASHZones or {} - table.insert(self.MASHZones, zoneObj) - _logDebug(string.format('[MobileMASH] Added zone to MASHZones array, total count: %d', #self.MASHZones)) - - -- Add to MEDEVAC zones if MEDEVAC is active - if self.MEDEVAC then - self.MEDEVAC:AddZone(displayName, zoneObj) - _logInfo(string.format('[MobileMASH] Added zone to MEDEVAC system: %s', displayName)) - -- Refresh MEDEVAC menu to include the new zone - self.MEDEVAC:__Start(1) - end - - local md = self.Config and self.Config.MapDraw or {} - if md.Enabled then - local ok, err = pcall(function() self:DrawZonesOnMap() end) - if not ok then - _logError(string.format('DrawZonesOnMap failed after Mobile MASH creation: %s', tostring(err))) - end - else - local circleId = _nextMarkupId() - local textId = _nextMarkupId() - local p = { x = initialPos.x, y = 0, z = initialPos.z } - - local colors = cfg.MASHZoneColors or {} - local borderColor = colors.border or {1, 1, 0, 0.85} - local fillColor = colors.fill or {1, 0.75, 0.8, 0.25} - - trigger.action.circleToCoalition(side, circleId, p, radius, borderColor, fillColor, 1, true, "") - - local textPos = { x = p.x, y = 0, z = p.z - radius - 50 } - trigger.action.textToCoalition(side, textId, textPos, {1,1,1,0.9}, {0,0,0,0}, 18, true, displayName) - - mashData.circleId = circleId - mashData.textId = textId - _logDebug(string.format('[MobileMASH] Drawn map circleId=%d textId=%d', circleId, textId)) - end - - local gridStr = self:_GetMGRSString(initialPos) - trigger.action.outTextForCoalition(side, _fmtTemplate(CTLD.Messages.medevac_mash_deployed, { - mash_id = index, - grid = gridStr, - freq = beaconFreq, - }), 30) - _logInfo(string.format('[MobileMASH] Mobile MASH "%s" registered at %s', displayName, gridStr)) - - if cfg.MobileMASH.AnnouncementInterval and cfg.MobileMASH.AnnouncementInterval > 0 then - local ctldInstance = self - local scheduler = SCHEDULER:New(nil, function() - local ok, err = pcall(function() - if not groupIsAlive() then - ctldInstance:_RemoveMobileMASH(mashId) - return - end - - local vec3 = groupVec3() - if vec3 then - mashData.position = { x = vec3.x, z = vec3.z } - if mashData.zone then - if mashData.zone.SetPointVec3 then - mashData.zone:SetPointVec3({ x = vec3.x, y = vec3.y or 0, z = vec3.z }) - elseif mashData.zone.SetVec2 then - mashData.zone:SetVec2({ x = vec3.x, y = vec3.z }) - end - end - local currentGrid = ctldInstance:_GetMGRSString({ x = vec3.x, z = vec3.z }) - trigger.action.outTextForCoalition(side, _fmtTemplate(CTLD.Messages.medevac_mash_announcement, { - mash_id = index, - grid = currentGrid, - freq = beaconFreq, - }), 20) - _logDebug(string.format('[MobileMASH] Announcement tick for %s at grid %s', displayName, tostring(currentGrid))) - end - end) - if not ok then _logError('Mobile MASH announcement scheduler error: '..tostring(err)) end - end, {}, cfg.MobileMASH.AnnouncementInterval, cfg.MobileMASH.AnnouncementInterval) - - mashData.scheduler = scheduler - _logDebug(string.format('[MobileMASH] Announcement scheduler started every %.1fs', cfg.MobileMASH.AnnouncementInterval)) - end - - -- Create a separate frequent position update scheduler for mobile MASH tracking - -- This ensures the zone follows the vehicle even if announcements are infrequent - local ctldInstance = self - local positionUpdateInterval = 5 -- Update position every 5 seconds - local posScheduler = SCHEDULER:New(nil, function() - local ok, err = pcall(function() - if not groupIsAlive() then - ctldInstance:_RemoveMobileMASH(mashId) - return - end - - local vec3 = groupVec3() - if vec3 then - mashData.position = { x = vec3.x, z = vec3.z } - if mashData.zone then - if mashData.zone.SetPointVec3 then - mashData.zone:SetPointVec3({ x = vec3.x, y = vec3.y or 0, z = vec3.z }) - elseif mashData.zone.SetVec2 then - mashData.zone:SetVec2({ x = vec3.x, y = vec3.z }) - end - end - _logDebug(string.format('[MobileMASH] Position updated for %s at (%.1f, %.1f)', displayName, vec3.x, vec3.z)) - end - end) - if not ok then _logError('Mobile MASH position update scheduler error: '..tostring(err)) end - end, {}, positionUpdateInterval, positionUpdateInterval) - - mashData.positionScheduler = posScheduler - _logDebug(string.format('[MobileMASH] Position update scheduler started every %ds', positionUpdateInterval)) - - if EVENTHANDLER then - local ctldInstance = self - local eventHandler = EVENTHANDLER:New() - eventHandler:HandleEvent(EVENTS.Dead) - - function eventHandler:OnEventDead(EventData) - local killedName = EventData.IniGroupName or (EventData.IniGroup and EventData.IniGroup:GetName()) - if killedName and killedName == mashGroupName then - ctldInstance:_RemoveMobileMASH(mashId) - end - end - - mashData.eventHandler = eventHandler - _logDebug(string.format('[MobileMASH] Event handler registered for group %s', tostring(mashGroupName))) - end - end - - if timer and timer.scheduleFunction and timer.getTime then - _logDebug('[MobileMASH] Scheduling finalizeMobileMASH via timer') - timer.scheduleFunction(function(_args, _time) - local ok, err = pcall(finalizeMobileMASH) - if not ok then - _logError(string.format('[MobileMASH] finalize failed: %s', tostring(err))) - end - return nil - end, {}, timer.getTime() + 0.2) - else - _logDebug('[MobileMASH] timer.scheduleFunction unavailable, running finalizeMobileMASH inline') - local ok, err = pcall(finalizeMobileMASH) - if not ok then - _logError(string.format('[MobileMASH] finalize failed: %s', tostring(err))) - end - end -end - --- Remove a Mobile MASH zone (on destruction or manual removal) -function CTLD:_RemoveMobileMASH(mashId) - if not CTLD._mashZones then return end - - local mash = CTLD._mashZones[mashId] - if mash then - -- Stop schedulers - if mash.scheduler then - mash.scheduler:Stop() - end - if mash.positionScheduler then - mash.positionScheduler:Stop() - end - - -- Remove map drawings - if mash.circleId then trigger.action.removeMark(mash.circleId) end - if mash.textId then trigger.action.removeMark(mash.textId) end - local name = mash.displayName or mashId - if self._ZoneDefs and self._ZoneDefs.MASHZones then self._ZoneDefs.MASHZones[name] = nil end - if self._ZoneActive and self._ZoneActive.MASH then self._ZoneActive.MASH[name] = nil end - self:_removeZoneDrawing('MASH', name) - - -- Remove from MASHZones array - if self.MASHZones and mash.zone then - for i = #self.MASHZones, 1, -1 do - if self.MASHZones[i] == mash.zone then - table.remove(self.MASHZones, i) - _logDebug(string.format('[MobileMASH] Removed zone from MASHZones array, remaining count: %d', #self.MASHZones)) - break - end - end - end - - -- Send destruction message - local msg = _fmtTemplate(CTLD.Messages.medevac_mash_destroyed, { - mash_id = string.match(mashId, 'MOBILE_MASH_%d+_(%d+)') or '?' - }) - trigger.action.outTextForCoalition(mash.side, msg, 20) - - -- Remove from table - CTLD._mashZones[mashId] = nil - _logVerbose(string.format('[MobileMASH] Removed MASH %s', mashId)) - if self.Config and self.Config.MapDraw and self.Config.MapDraw.Enabled then - pcall(function() self:DrawZonesOnMap() end) - end - end -end - --- #endregion Mobile MASH - --- #endregion Inventory helpers - --- Create a new Drop Zone (AO) at the player's current location and draw it on the map if enabled -function CTLD:CreateDropZoneAtGroup(group) - if not group or not group:IsAlive() then return end - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - -- Prevent creating a Drop Zone inside or too close to a Pickup Zone - -- 1) Block if inside a (potentially active-only) pickup zone - local activeOnlyForInside = (self.Config and self.Config.ForbidChecksActivePickupOnly ~= false) - local inside, pz, distInside, pr = self:_isUnitInsidePickupZone(unit, activeOnlyForInside) - if inside then - local isMetric = _getPlayerIsMetric(unit) - local curV, curU = _fmtRange(distInside or 0, isMetric) - local needV, needU = _fmtRange(self.Config.MinDropZoneDistanceFromPickup or 10000, isMetric) - _eventSend(self, group, nil, 'drop_zone_too_close_to_pickup', { - zone = (pz and pz.GetName and pz:GetName()) or '(pickup)', - need = needV, need_u = needU, - dist = curV, dist_u = curU, - }) - return - end - -- 2) Enforce a minimum distance from the nearest pickup zone (configurable) - local minD = tonumber(self.Config and self.Config.MinDropZoneDistanceFromPickup) or 0 - if minD > 0 then - local considerActive = (self.Config and self.Config.MinDropDistanceActivePickupOnly ~= false) - local nearestZone, nearestDist - if considerActive then - nearestZone, nearestDist = self:_nearestActivePickupZone(unit) - else - local list = (self.Config and self.Config.Zones and self.Config.Zones.PickupZones) or {} - nearestZone, nearestDist = _nearestZonePoint(unit, list) - end - if nearestZone and nearestDist and nearestDist < minD then - local isMetric = _getPlayerIsMetric(unit) - local needV, needU = _fmtRange(minD, isMetric) - local curV, curU = _fmtRange(nearestDist, isMetric) - _eventSend(self, group, nil, 'drop_zone_too_close_to_pickup', { - zone = (nearestZone and nearestZone.GetName and nearestZone:GetName()) or '(pickup)', - need = needV, need_u = needU, - dist = curV, dist_u = curU, - }) - return - end - end - local p = unit:GetPointVec3() - local baseName = group:GetName() or 'GROUP' - local safe = tostring(baseName):gsub('%W', '') - local name = string.format('AO_%s_%d', safe, math.random(100000,999999)) - local r = tonumber(self.Config and self.Config.DropZoneRadius) or 250 - local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(p.x, p.z) or { x = p.x, y = p.z } - local mz = ZONE_RADIUS:New(name, v2, r) - -- Register in runtime and config so other features can find it - self.DropZones = self.DropZones or {} - table.insert(self.DropZones, mz) - self._ZoneDefs = self._ZoneDefs or { PickupZones = {}, DropZones = {}, FOBZones = {} } - self._ZoneDefs.DropZones[name] = { name = name, radius = r, active = true } - self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {} } - self._ZoneActive.Drop[name] = true - self.Config.Zones = self.Config.Zones or { PickupZones = {}, DropZones = {}, FOBZones = {} } - self.Config.Zones.DropZones = self.Config.Zones.DropZones or {} - table.insert(self.Config.Zones.DropZones, { name = name, radius = r, active = true }) - -- Draw on map if configured - local md = self.Config and self.Config.MapDraw or {} - if md.Enabled and (md.DrawDropZones ~= false) then - local ok, err = pcall(function() self:DrawZonesOnMap() end) - if not ok then - _logError(string.format('DrawZonesOnMap failed after creating drop zone %s: %s', name, tostring(err))) - end - end - MESSAGE:New(string.format('Drop Zone created: %s (r≈%dm)', name, r), 10):ToGroup(group) -end - -function CTLD:AddPickupZone(z) - local mz = _findZone(z) - if mz then table.insert(self.PickupZones, mz); table.insert(self.Config.Zones.PickupZones, z) end -end - -function CTLD:AddDropZone(z) - local mz = _findZone(z) - if mz then table.insert(self.DropZones, mz); table.insert(self.Config.Zones.DropZones, z) end -end - -function CTLD:SetAllowedAircraft(list) - self.Config.AllowedAircraft = DeepCopy(list) -end - --- Explicit cleanup handler for mission end --- Call this to properly shut down all CTLD schedulers and clear state -function CTLD:Cleanup() - _logInfo('Cleanup initiated - stopping all schedulers and clearing state') - - -- Stop all smoke refresh schedulers - if CTLD._smokeRefreshSchedules then - for crateId, schedule in pairs(CTLD._smokeRefreshSchedules) do - if schedule.funcId then - pcall(function() timer.removeFunction(schedule.funcId) end) - end - end - CTLD._smokeRefreshSchedules = {} - end - - -- Stop all Mobile MASH schedulers - if CTLD._mashZones then - for mashId, mash in pairs(CTLD._mashZones) do - if mash.scheduler then - pcall(function() mash.scheduler:Stop() end) - end - if mash.eventHandler then - -- Event handlers clean themselves up, but we can nil the reference - mash.eventHandler = nil - end - end - end - - -- Stop any MEDEVAC timeout checkers or other schedulers - -- (If you add schedulers in the future, stop them here) - if self.MEDEVACSched then - pcall(function() self.MEDEVACSched:Stop() end) - self.MEDEVACSched = nil - end - if self.SalvageSched then - pcall(function() self.SalvageSched:Stop() end) - self.SalvageSched = nil - end - - -- Clear spatial grid - CTLD._spatialGrid = {} - - -- Clear state tables (optional - helps with memory in long-running missions) - CTLD._crates = {} - CTLD._troopsLoaded = {} - CTLD._loadedCrates = {} - CTLD._loadedTroopTypes = {} - CTLD._deployedTroops = {} - CTLD._hoverState = {} - CTLD._unitLast = {} - CTLD._coachState = {} - CTLD._msgState = {} - CTLD._buildConfirm = {} - CTLD._buildCooldown = {} - CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } - if self._loadedCrateMenus then - for _,state in pairs(self._loadedCrateMenus) do - if state and state.commands then - for _,cmd in ipairs(state.commands) do - if cmd and cmd.Remove then pcall(function() cmd:Remove() end) end - end - end - end - self._loadedCrateMenus = {} - end - - -- Clear salvage state - if CTLD._salvageCrates then - for crateName, meta in pairs(CTLD._salvageCrates) do - if meta.staticObject and meta.staticObject.destroy then - pcall(function() meta.staticObject:destroy() end) - end - end - CTLD._salvageCrates = {} - end - if self.JTACSched then - pcall(function() self.JTACSched:Stop() end) - self.JTACSched = nil - end - if self._jtacRegistry then - for groupName in pairs(self._jtacRegistry) do - self:_cleanupJTACEntry(groupName) - end - self._jtacRegistry = {} - end - - _logInfo('Cleanup complete') -end - --- Register mission end event to auto-cleanup --- This ensures resources are properly released -if not CTLD._cleanupHandlerRegistered then - CTLD._cleanupHandlerRegistered = true - - local cleanupHandler = EVENTHANDLER:New() - cleanupHandler:HandleEvent(EVENTS.MissionEnd) - - function cleanupHandler:OnEventMissionEnd(EventData) - _logInfo('Mission end detected - initiating cleanup') - -- Cleanup all instances - for _, instance in pairs(CTLD._instances or {}) do - if instance and instance.Cleanup then - pcall(function() instance:Cleanup() end) - end - end - -- Also call static cleanup - if CTLD.Cleanup then - pcall(function() CTLD:Cleanup() end) - end - end -end - --- #endregion Public helpers - --- ========================= --- Sling-Load Salvage System --- ========================= --- #region SlingLoadSalvage - --- Spawn a salvage crate when an enemy ground unit dies -function CTLD:_SpawnSlingLoadSalvageCrate(unitPos, unitTypeName, enemySide, eventData) - local cfg = self.Config.SlingLoadSalvage - if not cfg or not cfg.Enabled then return end - - -- Check spawn chance for this coalition - local spawnChance = cfg.SpawnChance[enemySide] or 0.15 - if math.random() > spawnChance then - _logVerbose(string.format('[SlingLoadSalvage] Spawn roll failed (%.2f chance)', spawnChance)) - return - end - - -- Check spawn restrictions - if cfg.NoSpawnNearPickupZones then - local minDist = cfg.NoSpawnNearPickupZoneDistance or 1000 - for _, zone in ipairs(self.PickupZones or {}) do - local zoneName = zone:GetName() - if zoneName and (self._ZoneActive.Pickup[zoneName] ~= false) then - local zonePos = zone:GetPointVec3() - local dist = math.sqrt((unitPos.x - zonePos.x)^2 + (unitPos.z - zonePos.z)^2) - if dist < minDist then - _logVerbose('[SlingLoadSalvage] Too close to pickup zone, aborting spawn') - return - end - end - end - end - - if cfg.NoSpawnNearAirbasesKm and cfg.NoSpawnNearAirbasesKm > 0 then - local airbases = coalition.getAirbases(enemySide) - if airbases then - local minDistKm = cfg.NoSpawnNearAirbasesKm * 1000 - for _, ab in ipairs(airbases) do - local abPos = ab:getPoint() - local dist = math.sqrt((unitPos.x - abPos.x)^2 + (unitPos.z - abPos.z)^2) - if dist < minDistKm then - _logVerbose('[SlingLoadSalvage] Too close to airbase, aborting spawn') - return - end - end - end - end - - -- Select weight class - local totalProb = 0 - for _, wc in ipairs(cfg.WeightClasses) do - totalProb = totalProb + wc.probability - end - local roll = math.random() * totalProb - local cumulative = 0 - local selectedClass = cfg.WeightClasses[1] -- fallback - for _, wc in ipairs(cfg.WeightClasses) do - cumulative = cumulative + wc.probability - if roll <= cumulative then - selectedClass = wc - break - end - end - - local weight = math.random(selectedClass.min, selectedClass.max) - local rewardValue = math.floor((weight / 500) * selectedClass.rewardPer500kg) - - -- Calculate spawn position - local minDist = cfg.MinSpawnDistance or 10 - local maxDist = cfg.MaxSpawnDistance or 25 - local distance = minDist + math.random() * (maxDist - minDist) - local angle = math.random() * 2 * math.pi - local spawnPos = { - x = unitPos.x + math.cos(angle) * distance, - z = unitPos.z + math.sin(angle) * distance - } - - -- Get land height - local landHeight = land.getHeight({ x = spawnPos.x, y = spawnPos.z }) - - -- Select cargo type based on weight - local cargoType - if weight < 1500 then - -- Light: barrels or ammo pallets - local lightTypes = { 'barrels_cargo', 'ammo_cargo' } - cargoType = lightTypes[math.random(1, #lightTypes)] - elseif weight < 2500 then - -- Medium: fuel tanks or containers - local mediumTypes = { 'fueltank_cargo', 'container_cargo', 'ammo_cargo' } - cargoType = mediumTypes[math.random(1, #mediumTypes)] - else - -- Heavy: large containers only - cargoType = 'container_cargo' - end - - -- Create unique crate name - -- Use prefix that matches the coalition allowed to collect this crate - local sidePrefix = (enemySide == coalition.side.BLUE) and 'B' or 'R' - local crateName = string.format('SALVAGE-%s-%04dKG-%06d', sidePrefix, weight, math.random(100000, 999999)) - - -- Enforce active salvage crate cap before spawning - if cfg.MaxActiveCrates then - local activeCount = 0 - for cname, meta in pairs(CTLD._salvageCrates or {}) do - if meta and meta.side == enemySide then - activeCount = activeCount + 1 - end - end - if activeCount >= cfg.MaxActiveCrates then - _logVerbose(string.format('[SlingLoadSalvage] Max active crates (%d) reached for side %d; skipping spawn', cfg.MaxActiveCrates, enemySide)) - return - end - end - - -- Spawn the static cargo - -- Spawn the crate for the coalition that can recover it (enemySide) - local countryId = nil - if CTLD._instances then - for _, inst in ipairs(CTLD._instances) do - if inst and inst.Side == enemySide and inst.CountryId then - countryId = inst.CountryId - break - end - end - end - if not countryId then - countryId = _defaultCountryForSide(enemySide) - end - - local staticData = { - ['type'] = cargoType, - ['name'] = crateName, - ['x'] = spawnPos.x, - ['y'] = spawnPos.z, - ['heading'] = math.random() * 2 * math.pi, - ['canCargo'] = true, - ['mass'] = weight, - } - - local success, staticObj = pcall(function() - return coalition.addStaticObject(countryId, staticData) - end) - - if not success or not staticObj then - _logError('[SlingLoadSalvage] Failed to spawn salvage crate: ' .. tostring(staticObj)) - return - end - - -- Store crate metadata - CTLD._salvageCrates[crateName] = { - side = enemySide, - weight = weight, - spawnTime = timer.getTime(), - position = spawnPos, - initialHealth = 1.0, - rewardValue = rewardValue, - warningsSent = {}, - staticObject = staticObj, - crateClass = selectedClass.name, - } - - -- Update stats - if not CTLD._salvageStats[enemySide] then - CTLD._salvageStats[enemySide] = { spawned = 0, delivered = 0, expired = 0, totalWeight = 0, totalReward = 0 } - end - CTLD._salvageStats[enemySide].spawned = CTLD._salvageStats[enemySide].spawned + 1 - - -- Spawn smoke if enabled (use unified crate smoke offset logic) - if cfg.SpawnSmoke then - local smokeColor = cfg.SmokeColor or trigger.smokeColor.Orange - -- Reuse crate smoke offset parameters but force Enabled for salvage spawn event - local baseCfg = self.Config.CrateSmoke or {} - local smokeConfig = { - Enabled = true, -- always allow initial salvage smoke when SlingLoadSalvage.SpawnSmoke = true - AutoRefresh = false, -- do not auto-refresh salvage smoke unless we explicitly add support later - RefreshInterval = baseCfg.RefreshInterval, - MaxRefreshDuration = baseCfg.MaxRefreshDuration, - OffsetMeters = baseCfg.OffsetMeters, - OffsetRandom = (baseCfg.OffsetRandom ~= false), - OffsetVertical = baseCfg.OffsetVertical, - } - -- Provide a position table compatible with _spawnCrateSmoke (y = ground height) - _spawnCrateSmoke({ x = spawnPos.x, y = landHeight, z = spawnPos.z }, smokeColor, smokeConfig, crateName) - end - - -- Calculate expiration time - local lifetime = cfg.CrateLifetime or 10800 - local timeRemainMin = math.floor(lifetime / 60) - local timeRemainHrs = math.floor(timeRemainMin / 60) - local timeRemainStr - if timeRemainHrs >= 1 then - timeRemainStr = string.format("%d hr%s", timeRemainHrs, timeRemainHrs > 1 and "s" or "") - else - timeRemainStr = string.format("%d min%s", timeRemainMin, timeRemainMin > 1 and "s" or "") - end - local grid = self:_GetMGRSString(spawnPos) - - -- Announce to coalition - local msg = _fmtTemplate(self.Messages.slingload_salvage_spawned, { - grid = grid, - weight = weight, - reward = rewardValue, - time_remain = timeRemainStr, - }) - _msgCoalition(enemySide, msg) - - _logInfo(string.format('[SlingLoadSalvage] Spawned %s: weight=%dkg, reward=%dpts at %s', - crateName, weight, rewardValue, grid)) -end - --- Check salvage crates for delivery and cleanup -function CTLD:_CheckSlingLoadSalvageCrates() - local cfg = self.Config.SlingLoadSalvage - if not cfg or not cfg.Enabled then return end - - local now = timer.getTime() - local cratesToRemove = {} - - for crateName, meta in pairs(CTLD._salvageCrates) do - if meta.side == self.Side then - local elapsed = now - meta.spawnTime - local lifetime = cfg.CrateLifetime or 10800 - - -- Check for expiration (skip for manual crates) - if elapsed >= lifetime and not meta.isManual then - table.insert(cratesToRemove, crateName) - - -- Update stats - CTLD._salvageStats[meta.side].expired = CTLD._salvageStats[meta.side].expired + 1 - - -- Announce expiration - local grid = self:_GetMGRSString(meta.position) - local msg = _fmtTemplate(self.Messages.slingload_salvage_expired, { - id = crateName, - grid = grid, - }) - _msgCoalition(meta.side, msg) - - -- Remove the static object - if meta.staticObject and meta.staticObject.destroy then - pcall(function() meta.staticObject:destroy() end) - end - - _logVerbose(string.format('[SlingLoadSalvage] Crate %s expired', crateName)) - - else - -- Check for warnings (skip for manual crates) - if not meta.isManual then - local remaining = lifetime - elapsed - for _, warnTime in ipairs(cfg.WarningTimes or { 1800, 300 }) do - if remaining <= warnTime and not meta.warningsSent[warnTime] then - meta.warningsSent[warnTime] = true - local grid = self:_GetMGRSString(meta.position) - local msgKey = (warnTime >= 1800) and 'slingload_salvage_warn_30min' or 'slingload_salvage_warn_5min' - local msg = _fmtTemplate(self.Messages[msgKey], { - id = crateName, - grid = grid, - weight = meta.weight, - }) - _msgCoalition(meta.side, msg) - end - end - end - - -- Check if crate is in a salvage zone - if meta.staticObject and meta.staticObject:isExist() then - local cratePos = meta.staticObject:getPoint() - if cratePos then - -- Check all salvage zones for this coalition - for _, zone in ipairs(self.SalvageDropZones or {}) do - local zoneName = zone:GetName() - local zoneDef = self._ZoneDefs.SalvageDropZones[zoneName] - - if zoneDef and zoneDef.side == meta.side and (self._ZoneActive.SalvageDrop[zoneName] ~= false) then - -- cratePos is a DCS Vec3 table, so use the direct Vec3 helper to avoid GetVec2 calls - if zone:IsVec3InZone(cratePos) then - -- Simple CTLD.lua style: just check if crate is in air - local crateHooked = _isCrateHooked(meta.staticObject) - - if not crateHooked then - -- Crate is on the ground in the zone - deliver it! - _logInfo(string.format('[SlingLoadSalvage] Delivering %s', crateName)) - self:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) - table.insert(cratesToRemove, crateName) - break - else - -- Crate is still hooked - send hint and wait for release - self:_SendSalvageHint(meta, 'slingload_salvage_hooked_in_zone', { - id = crateName, - zone = zoneName, - }, cratePos, 8) - _logDebug(string.format('[SlingLoadSalvage] Crate %s still hooked, waiting for release', crateName)) - end - end - end - end - -- Provide guidance if crate is lingering inside other zone types - self:_CheckCrateZoneHints(crateName, meta, cratePos) - end - else - -- Crate no longer exists (destroyed or removed) - table.insert(cratesToRemove, crateName) - _logVerbose(string.format('[SlingLoadSalvage] Crate %s no longer exists', crateName)) - end - end - end - end - - -- Remove processed crates - for _, crateName in ipairs(cratesToRemove) do - CTLD._salvageCrates[crateName] = nil - end -end - --- Deliver a salvage crate and award points -function CTLD:_DeliverSlingLoadSalvageCrate(crateName, meta, zoneName) - local cfg = self.Config.SlingLoadSalvage - - -- Check crate health for condition multiplier - local healthRatio = 1.0 - if meta.staticObject and meta.staticObject.getLife then - local success, currentLife = pcall(function() return meta.staticObject:getLife() end) - if success and currentLife then - local success2, maxLife = pcall(function() return meta.staticObject:getLife0() end) - if success2 and maxLife and maxLife > 0 then - healthRatio = currentLife / maxLife - end - end - end - - -- Determine condition multiplier - local conditionMult = cfg.ConditionMultipliers.Damaged or 1.0 - local conditionLabel = "Damaged" - if healthRatio >= 0.9 then - conditionMult = cfg.ConditionMultipliers.Undamaged or 1.5 - conditionLabel = "Undamaged" - elseif healthRatio < 0.5 then - conditionMult = cfg.ConditionMultipliers.HeavyDamage or 0.5 - conditionLabel = "Heavy Damage" - end - - -- Calculate final reward - local finalReward = math.floor(meta.rewardValue * conditionMult) - - -- Award salvage points - CTLD._salvagePoints[meta.side] = (CTLD._salvagePoints[meta.side] or 0) + finalReward - - -- Update stats - CTLD._salvageStats[meta.side].delivered = CTLD._salvageStats[meta.side].delivered + 1 - CTLD._salvageStats[meta.side].totalWeight = CTLD._salvageStats[meta.side].totalWeight + meta.weight - CTLD._salvageStats[meta.side].totalReward = CTLD._salvageStats[meta.side].totalReward + finalReward - - -- Find the player who delivered (nearest transport helo in zone) - local playerName = "Unknown Pilot" - local deliveryUnit = nil - for _, zone in ipairs(self.SalvageDropZones or {}) do - if zone:GetName() == zoneName then - -- Find nearby friendly helicopters - local zonePos = zone:GetPointVec3() - local radius = self:_getZoneRadius(zone) or 300 - local nearbyUnits = {} - - -- Search for units in the zone - local sphere = _buildSphereVolume(zonePos, radius) - - local foundUnits = {} - world.searchObjects(Object.Category.UNIT, sphere, function(obj) - if obj and obj:isExist() and obj.getCoalition then - local objCoal = obj:getCoalition() - if objCoal == meta.side and obj.getGroup then - local grp = obj:getGroup() - if grp then - local grpName = grp:getName() - table.insert(foundUnits, { unit = obj, group = grp, groupName = grpName }) - end - end - end - return true - end) - - -- Find player name from group - if #foundUnits > 0 then - deliveryUnit = foundUnits[1].unit - local grpName = foundUnits[1].groupName - if grpName then - -- Try to extract player name from group - local mooseGrp = GROUP:FindByName(grpName) - if mooseGrp then - local unit1 = mooseGrp:GetUnit(1) - if unit1 then - local pName = unit1:GetPlayerName() - if pName and pName ~= '' then - playerName = pName - else - playerName = grpName - end - end - end - end - end - break - end - end - - -- Announce delivery - local msg = _fmtTemplate(self.Messages.slingload_salvage_delivered, { - player = playerName, - weight = meta.weight, - reward = finalReward, - condition = conditionLabel, - total = CTLD._salvagePoints[meta.side], - }) - local quip - local quipPool = self.Messages.slingload_salvage_received_quips - if quipPool and #quipPool > 0 then - local template = quipPool[math.random(#quipPool)] - if template and template ~= '' then - quip = _fmtTemplate(template, { - player = playerName, - zone = zoneName, - reward = finalReward, - weight = meta.weight, - condition = conditionLabel, - total = CTLD._salvagePoints[meta.side], - coalition = (self.Side == coalition.side.BLUE) and 'BLUE' or 'RED', - }) - end - end - if quip and quip ~= '' then - msg = msg .. '\n' .. quip - end - _msgCoalition(meta.side, msg) - - -- Remove the crate - if meta.staticObject and meta.staticObject.destroy then - pcall(function() meta.staticObject:destroy() end) - end - - _logInfo(string.format('[SlingLoadSalvage] %s delivered %s: %dkg, %dpts (%s), total=%d', - playerName, crateName, meta.weight, finalReward, conditionLabel, CTLD._salvagePoints[meta.side])) -end - --- Menu: Create Salvage Zone at group position -function CTLD:CreateSalvageZoneAtGroup(group) - local cfg = self.Config.SlingLoadSalvage - if not cfg or not cfg.Enabled then - _msgGroup(group, 'Sling-Load Salvage system is disabled.') - return - end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local pos = unit:GetPointVec3() - local coord = COORDINATE:NewFromVec3(pos) - local radius = cfg.DefaultZoneRadius or 300 - - -- Check for nearby salvage cargo (prevent zone placement within 1km of cargo) - local minDistance = 1000 -- 1km - for crateName, meta in pairs(CTLD._salvageCrates or {}) do - if meta and meta.position then - local cratePos = meta.position - local distance = math.sqrt((pos.x - cratePos.x)^2 + (pos.z - cratePos.z)^2) - if distance < minDistance then - _msgGroup(group, string.format('Cannot create salvage zone within %.0fm of existing salvage cargo. Nearest cargo is %.0fm away.', minDistance, distance)) - return - end - end - end - - self._DynamicSalvageZones = self._DynamicSalvageZones or {} - self._DynamicSalvageQueue = self._DynamicSalvageQueue or {} - - -- Pre-clean existing dynamics so limit checks are up to date - self:_enforceDynamicSalvageZoneLimit() - - -- Generate unique zone name - local zoneName = string.format('SalvageZone-%s-%d', (self.Side == coalition.side.BLUE and 'BLUE' or 'RED'), - math.random(1000, 9999)) - - -- Create MOOSE zone - local zone = ZONE_RADIUS:New(zoneName, coord:GetVec2(), radius) - - -- Add to instance zones - table.insert(self.SalvageDropZones, zone) - local now = timer and timer.getTime and timer.getTime() or 0 - local lifetime = tonumber(cfg.DynamicZoneLifetime or 0) or 0 - local expiresAt = (lifetime > 0) and (now + lifetime) or nil - - self._ZoneDefs.SalvageDropZones[zoneName] = { - name = zoneName, - side = self.Side, - active = true, - radius = radius, - dynamic = true, - createdAt = now, - expiresAt = expiresAt, - } - self._ZoneActive.SalvageDrop[zoneName] = true - - -- Track dynamic zone metadata for cleanup enforcement - self._DynamicSalvageZones[zoneName] = { - zone = zone, - createdAt = now, - expiresAt = expiresAt, - radius = radius, - } - table.insert(self._DynamicSalvageQueue, zoneName) - - -- Enforce limits after registering the new zone - self:_enforceDynamicSalvageZoneLimit() - if not self._DynamicSalvageZones[zoneName] then - _msgGroup(group, 'Unable to create salvage zone (limit reached). Older zones were not cleared in time.') - return - end - - -- Announce - local msg = _fmtTemplate(self.Messages.slingload_salvage_zone_created, { - zone = zoneName, - radius = radius, - }) - if lifetime > 0 then - msg = msg .. string.format(' Expires in %s.', _ctldFormatSeconds(lifetime)) - end - local maxZones = tonumber(cfg.MaxDynamicZones or 0) or 0 - if maxZones > 0 then - msg = msg .. string.format(' Active zone cap: %d.', maxZones) - end - _msgGroup(group, msg) - - _logInfo(string.format('[SlingLoadSalvage] Created zone %s at %s', zoneName, coord:ToStringLLDMS())) - - local ok, err = pcall(function() self:DrawZonesOnMap() end) - if not ok then - _logError(string.format('[SlingLoadSalvage] DrawZonesOnMap failed after creating %s: %s', zoneName, tostring(err))) - end -end - -function CTLD:RetireOldestDynamicSalvageZone(group) - local cfg = self.Config.SlingLoadSalvage - if not cfg or not cfg.Enabled then return end - - self._DynamicSalvageQueue = self._DynamicSalvageQueue or {} - if #self._DynamicSalvageQueue == 0 then - if group then _msgGroup(group, 'No dynamic salvage zones to retire.') end - return - end - - local oldest = self._DynamicSalvageQueue[1] - if not oldest then - if group then _msgGroup(group, 'No dynamic salvage zones to retire.') end - return - end - - local exists = self._DynamicSalvageZones and self._DynamicSalvageZones[oldest] - if exists then - self:_removeDynamicSalvageZone(oldest, 'manual-retire') - if group then - _msgGroup(group, string.format('Retired salvage zone: %s', oldest)) - end - else - -- Remove stale entry from queue and notify user - table.remove(self._DynamicSalvageQueue, 1) - if group then _msgGroup(group, 'Oldest salvage zone already retired.') end - end -end - --- Menu: Show active salvage zones -function CTLD:ShowActiveSalvageZones(group) - local cfg = self.Config.SlingLoadSalvage - if not cfg or not cfg.Enabled then return end - - local activeZones = {} - for _, zone in ipairs(self.SalvageDropZones or {}) do - local zoneName = zone:GetName() - if self._ZoneActive.SalvageDrop[zoneName] ~= false then - local zoneDef = self._ZoneDefs.SalvageDropZones[zoneName] - if zoneDef and zoneDef.side == self.Side then - table.insert(activeZones, zoneName) - end - end - end - - if #activeZones == 0 then - _msgGroup(group, 'No active Salvage Collection Zones configured.') - else - local msg = 'Active Salvage Collection Zones:\n' .. table.concat(activeZones, '\n') - _msgGroup(group, msg) - end -end - --- Menu: Show nearest salvage crate vectors -function CTLD:ShowNearestSalvageCrate(group) - local cfg = self.Config.SlingLoadSalvage - if not cfg or not cfg.Enabled then return end - - local unit = group:GetUnit(1) - if not unit or not unit:IsAlive() then return end - - local pos = unit:GetPointVec3() - local here = { x = pos.x, z = pos.z } - - local nearestName, nearestMeta, nearestDist = nil, nil, math.huge - for crateName, meta in pairs(CTLD._salvageCrates) do - if meta.side == self.Side then - local dx = meta.position.x - here.x - local dz = meta.position.z - here.z - local dist = math.sqrt(dx*dx + dz*dz) - if dist < nearestDist then - nearestDist = dist - nearestName = crateName - nearestMeta = meta - end - end - end - - if not nearestName then - local msg = self.Messages.slingload_salvage_no_crates or 'No active salvage crates available.' - _msgGroup(group, msg) - return - end - - local brg = _bearingDeg(here, nearestMeta.position) - local isMetric = _getPlayerIsMetric(unit) - local rng, rngU = _fmtRange(nearestDist, isMetric) - - local msg = _fmtTemplate(self.Messages.slingload_salvage_vectors, { - id = nearestName, - brg = brg, - rng = rng, - rng_u = rngU, - weight = nearestMeta.weight, - reward = nearestMeta.rewardValue, - }) - _msgGroup(group, msg) -end - --- #endregion SlingLoadSalvage - --- #endregion Public helpers - --- ========================= --- Return factory --- ========================= --- #region Export -_MOOSE_CTLD = CTLD -return CTLD --- #endregion Export - -