-- 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) -- 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. -- Table of Contents (navigation) -- 1) Config (version, messaging, main Config table) -- 2) State -- 3) Utilities -- 4) Construction (zones, bindings, init) -- 5) Menus (group/coalition, dynamic lists) -- 6) Coalition Summary -- 7) Crates (request/spawn, nearby, cleanup) -- 8) Build logic -- 9) Loaded crate management -- 10) Hover pickup scanner -- 11) Troops -- 12) Auto-build FOB in zones -- 13) Inventory helpers -- 14) Public helpers (catalog registration/merge) -- 15) Export -- ========================= if not _G.BASE then env.info('[Moose_CTLD] Moose (BASE) not detected. Ensure Moose.lua is loaded before Moose_CTLD.lua') end local CTLD = {} CTLD.__index = CTLD -- 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) -- simple heuristic 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 -- ========================= -- Defaults and State -- ========================= -- #region Config CTLD.Version = '0.1.0-alpha' -- 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}. Call for vectors if you need a hand.", -- 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.", -- 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.", -- 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_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 and insufficient salvage. Need {need} salvage points (have {have}). Deliver MEDEVAC crews to MASH to earn more.", 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!", -- 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 = { CoalitionSide = coalition.side.BLUE, -- default coalition this instance serves (menus created for this side) 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' }, -- 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 }, -- 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) }, UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide CreateMenuAtMissionStart = false, -- if true with UseGroupMenus=true, creates empty root menu at mission start to reserve F10 position (populated on player spawn) RootMenuName = 'CTLD', -- Name for the root F10 menu. Note: Menu ordering depends on script load order in mission editor. UseCategorySubmenus = true, -- if true, organize crate requests by category submenu (menuCategory) UseBuiltinCatalog = false, -- if false, starts with an empty catalog; intended when you preload a global catalog and want only that -- Safety offsets to avoid spawning units too close to player aircraft BuildSpawnOffset = 40, -- meters: shift build point forward from the aircraft to avoid rotor/ground collisions (0 = spawn centered on aircraft) TroopSpawnOffset = 40, -- meters: shift troop unload point forward from the aircraft -- Air-spawn settings for CTLD-built drones (AIRPLANE category entries in the catalog 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 = 3048, -- default spawn altitude ASL (meters) - 10,000 feet SpeedMps = 120 -- default initial speed in m/s }, DropCrateForwardOffset = 35, -- meters: drop loaded crates this far in front of the aircraft (instead of directly under) RestrictFOBToZones = false, -- if true, recipes marked isFOB only build inside configured FOBZones AutoBuildFOBInZones = false, -- if true, CTLD auto-builds FOB recipes when required crates are inside a FOB zone BuildRadius = 60, -- meters around build point to collect crates CrateLifetime = 3600, -- seconds before crates auto-clean up; 0 = disable MessageDuration = 15, -- seconds for on-screen messages Debug = false, -- Ground requirements for loading (realistic behavior) RequireGroundForTroopLoad = true, -- if true, must be landed to load troops (prevents loading while hovering) RequireGroundForVehicleLoad = true, -- if true, must be landed to load vehicles (C-130/large transports) MaxGroundSpeedForLoading = 2.0, -- meters/second: max ground speed allowed for loading (prevents loading while taxiing fast; ~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) -- 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, -- after a successful build, impose a cooldown before allowing another build by the same group BuildCooldownSeconds = 60, -- seconds of cooldown after a successful build per group 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, -- if true, spawn smoke when crates are created; if false, no smoke at all AutoRefresh = false, -- if true, automatically spawn new smoke every RefreshInterval seconds (creates continuous smoke) RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true; 240s = 4min recommended) MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit; 600s = 10min; set high or disable AutoRefresh for one-time smoke) OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke (0 = directly on crate) 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; 2-3 recommended) }, RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone RequirePickupZoneForTroopLoad = true, -- if 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 -- Safety rules around Supply (Pickup) Zones ForbidDropsInsidePickupZones = true, -- if true, players cannot drop crates while inside a Pickup Zone ForbidTroopDeployInsidePickupZones = true, -- if true, players cannot deploy troops while inside a Pickup Zone ForbidChecksActivePickupOnly = true, -- when true, restriction applies only to ACTIVE pickup zones; set false to block inside any configured pickup zone -- Dynamic Drop Zone settings DropZoneRadius = 250, -- 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 -- Attack/Defend AI behavior for deployed troops and built vehicles AttackAI = { Enabled = true, -- master switch for attack behavior TroopSearchRadius = 3000, -- meters: when deploying troops with Attack, search radius for targets/bases VehicleSearchRadius = 6000, -- meters: when building vehicles with Attack, search radius PrioritizeEnemyBases = true, -- if true, prefer enemy-held bases over ground units when both are in range TroopAdvanceSpeedKmh = 20, -- movement speed for troops when ordered to attack VehicleAdvanceSpeedKmh = 35, -- movement speed for vehicles when ordered to attack }, -- 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 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 }, 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 }, -- 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 Zone', Drop = 'Drop Zone', FOB = 'FOB Zone', MASH = 'MASH Zone', } }, -- Crate spawn placement within pickup zones PickupZoneSpawnRandomize = true, -- if true, spawn crates at a random point within the pickup zone (avoids stacking) PickupZoneSpawnEdgeBuffer = 10, -- meters: keep spawns at least this far inside the zone edge PickupZoneSpawnMinOffset = 100, -- 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 BuildRequiresGroundCrates = true, -- if true, all required crates must be on the ground (won't count/consume carried crates) -- 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, -- if 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 = { -- Default troop type to use when no specific type is chosen DefaultType = 'AS', -- Team definitions: loaded from catalog via _CTLD_TROOP_TYPES global -- If no catalog is loaded, empty table is used (and fallback logic applies) TroopTypes = {}, }, -- Zones (Supply/Pickup, Drop, FOB, MASH) -- 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) }, } -- #endregion Config -- 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 = 5, -- m: horizontal sweet spot radius captureVert = 5, -- 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 }, } -- ========================= -- 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 = true, -- if true, crew stays immortal even after announcing mission (easier gameplay) -- 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 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!", }, -- 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 -- 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] = 'M113', -- 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) }, } -- #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._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 -- Inventory state CTLD._stockByZone = CTLD._stockByZone or {} -- [zoneName] = { [crateKey] = count } CTLD._inStockMenus = CTLD._inStockMenus or {} -- per-group filtered menu handles -- 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 }, } -- #endregion State -- ========================= -- Utilities -- ========================= -- #region Utilities 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 _msgGroup(group, text, t) if not group then return end MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToGroup(group) end local function _msgCoalition(side, text, t) MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToCoalition(side) end 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 groundSpeed = math.sqrt(vel.x * vel.x + vel.z * vel.z) -- 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 then return 0 end return math.sqrt(vel.x * vel.x + vel.z * vel.z) 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 = {} -- 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) end end -- From MOOSE zone objects if present 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 then table.insert(out, { name = n }) end end end end return out end function CTLD:_nearestActivePickupZone(unit) return _nearestZonePoint(unit, self:_collectActivePickupDefs()) end local function _coalitionAddGroup(side, category, groupData, ctldConfig) -- Enforce side/category in groupData just to be safe groupData.category = category -- 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 return coalition.addGroup(side, 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 -- Spawn smoke at a position using MOOSE COORDINATE smoke (better appearance) or trigger smoke (old thick ground smoke) -- position: {x, y, z} table (Vec3) -- color: trigger.smokeColor enum value -- config: reference to a CrateSmoke config table (or nil to use defaults) -- crateId: optional crate identifier for tracking smoke refresh schedules 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 smoke is disabled, skip entirely if not enabled then return end -- Apply offset to smoke position so helicopters don't hover in the smoke local smokePos = { x = position.x, y = position.y, z = position.z } 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 -- Apply vertical offset (above ground level) smokePos.y = smokePos.y + offsetVertical -- Spawn the smoke using MOOSE COORDINATE (better appearance than trigger.action.smoke) local coord = COORDINATE:New(smokePos.x, smokePos.y, smokePos.z) if coord and coord.Smoke then -- MOOSE smoke method - produces better looking smoke similar to F6 cargo smoke 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() -- default end else -- Fallback to trigger.action.smoke if MOOSE COORDINATE not available trigger.action.smoke(smokePos, color) end -- Schedule smoke refresh if enabled if autoRefresh and crateId and refreshInterval > 0 and maxRefreshDuration > 0 then CTLD._smokeRefreshSchedules = CTLD._smokeRefreshSchedules or {} -- Clear any existing schedule for this crate if CTLD._smokeRefreshSchedules[crateId] then timer.removeFunction(CTLD._smokeRefreshSchedules[crateId].funcId) end local startTime = timer.getTime() local capturedColor = color -- Capture variables for the closure local capturedOffsetMeters = offsetMeters local capturedOffsetRandom = offsetRandom local capturedOffsetVertical = offsetVertical local function refreshSmoke() local elapsed = timer.getTime() - startTime if elapsed >= maxRefreshDuration then -- Max refresh duration exceeded, stop refreshing (safety limit) CTLD._smokeRefreshSchedules[crateId] = nil return nil end -- Check if crate still exists if not CTLD._crates or not CTLD._crates[crateId] then -- Crate was picked up, built, or cleaned up - stop refreshing CTLD._smokeRefreshSchedules[crateId] = nil return nil end -- Refresh smoke at crate position local crateMeta = CTLD._crates[crateId] if crateMeta and crateMeta.point then local sy = 0 if land and land.getHeight then local ok, h = pcall(land.getHeight, { x = crateMeta.point.x, y = crateMeta.point.z }) if ok and type(h) == 'number' then sy = h end end -- Apply offset to smoke position local refreshSmokePos = { x = crateMeta.point.x, y = sy, z = crateMeta.point.z } if capturedOffsetMeters > 0 then local angle = 0 -- North by default if capturedOffsetRandom then angle = math.random() * 2 * math.pi -- Random direction end refreshSmokePos.x = refreshSmokePos.x + capturedOffsetMeters * math.cos(angle) refreshSmokePos.z = refreshSmokePos.z + capturedOffsetMeters * math.sin(angle) end -- Apply vertical offset refreshSmokePos.y = refreshSmokePos.y + capturedOffsetVertical local refreshCoord = COORDINATE:New(refreshSmokePos.x, refreshSmokePos.y, refreshSmokePos.z) if refreshCoord and refreshCoord.Smoke then if capturedColor == trigger.smokeColor.Green then refreshCoord:SmokeGreen() elseif capturedColor == trigger.smokeColor.Red then refreshCoord:SmokeRed() elseif capturedColor == trigger.smokeColor.White then refreshCoord:SmokeWhite() elseif capturedColor == trigger.smokeColor.Orange then refreshCoord:SmokeOrange() elseif capturedColor == trigger.smokeColor.Blue then refreshCoord:SmokeBlue() else refreshCoord:SmokeGreen() end else trigger.action.smoke(refreshSmokePos, capturedColor) end end return timer.getTime() + refreshInterval end local funcId = timer.scheduleFunction(refreshSmoke, nil, timer.getTime() + refreshInterval) CTLD._smokeRefreshSchedules[crateId] = { funcId = funcId, startTime = startTime } end end -- Clean up smoke refresh schedule for a crate local function _cleanupCrateSmoke(crateId) if not crateId then return end CTLD._smokeRefreshSchedules = CTLD._smokeRefreshSchedules or {} if CTLD._smokeRefreshSchedules[crateId] then if CTLD._smokeRefreshSchedules[crateId].funcId then timer.removeFunction(CTLD._smokeRefreshSchedules[crateId].funcId) end CTLD._smokeRefreshSchedules[crateId] = nil 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 = {} } 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 = {} } 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' env.info(string.format('[Moose_CTLD] 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 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 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 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) if isMetric then local v = math.max(0, _round(meters, 0)) return v, 'm' else local nm = meters / 1852 return _round(nm, 1), '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 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 -- 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 local mg local ok = pcall(function() mg = GROUP:FindByName(groupName) end) if ok and mg then local vec2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(targetPoint.x, targetPoint.z) or { x = targetPoint.x, y = targetPoint.z } -- RouteGroundTo(speed km/h). Use pcall to avoid mission halt if API differs. local _, _ = pcall(function() mg:RouteGroundTo(vec2, speedKmh or 25) end) return end -- Fallback: DCS Group controller simple mission to single waypoint local dg = Group.getByName(groupName) if not dg then return end local ctrl = dg:getController() if not ctrl then return end -- Try to set a simple go-to task local task = { id = 'Mission', params = { route = { points = { { x = targetPoint.x, y = targetPoint.z, speed = 5, action = 'Off Road', task = {}, type = 'Turning Point', ETA = 0, ETA_locked = false, } } } } } pcall(function() ctrl:setTask(task) end) 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 return end 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' -- Try to infer last requesting player from crate/troop context is complex; caller should pass announcements separately when needed. -- Target selection local target local pickedBase 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 end end if not target then local eg = self:_findNearestEnemyGround(originPoint, radius) if eg then target = { point = eg.point, name = eg.dcsGroupName, kind = 'enemy', dist = eg.dist, etype = eg.type } end end -- 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 {} 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 function _eventSend(self, group, side, key, data) local tpl = CTLD.Messages and CTLD.Messages[key] if not tpl then return end 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 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 env.info('[Moose_CTLD][DEBUG] After config merge:') env.info('[Moose_CTLD][DEBUG] o.Config.Zones exists: '..tostring(o.Config.Zones ~= nil)) if o.Config.Zones then env.info('[Moose_CTLD][DEBUG] o.Config.Zones.MASHZones exists: '..tostring(o.Config.Zones.MASHZones ~= nil)) if o.Config.Zones.MASHZones then env.info('[Moose_CTLD][DEBUG] o.Config.Zones.MASHZones count: '..tostring(#o.Config.Zones.MASHZones)) end end o.Side = o.Config.CoalitionSide o.MenuRoots = {} o.MenusByGroup = {} -- 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) if o.Config.Debug then env.info('[Moose_CTLD] Merged crate catalog from global '..gn) end 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 if o.Config.Debug then env.info('[Moose_CTLD] Loaded troop types from _CTLD_TROOP_TYPES') end else -- Fallback: catalog not loaded, warn user and provide minimal defaults if o.Config.Debug then env.info('[Moose_CTLD] WARNING: _CTLD_TROOP_TYPES not found. Catalog may not be loaded. Using minimal troop fallbacks.') env.info('[Moose_CTLD] Please ensure catalog file is loaded via DO SCRIPT FILE *before* creating CTLD instances.') end -- 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 = { 'Mortar M252' }, unitsRed = { '2B11 mortar' }, units = { 'Infantry AK' } }, } end end 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) o._BindingsMerged = merged if o._BindingsMerged and #o._BindingsMerged > 0 then o._ZoneFlagState = {} o._ZoneFlagsPrimed = false o.ZoneFlagSched = SCHEDULER:New(nil, 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, {}, 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 -- Periodic cleanup for crates o.Sched = SCHEDULER:New(nil, function() o:CleanupCrates() end, {}, 60, 60) -- Periodic cleanup for deployed troops (remove dead/missing groups) o.TroopCleanupSched = SCHEDULER:New(nil, function() o:CleanupDeployedTroops() end, {}, 30, 30) -- Optional: auto-build FOBs inside FOB zones when crates present if o.Config.AutoBuildFOBInZones then o.AutoFOBSched = SCHEDULER:New(nil, function() o:AutoBuildFOBCheck() end, {}, 10, 10) -- check every 10 seconds end -- Optional: hover pickup scanner local coachCfg = CTLD.HoverCoachConfig or {} if coachCfg.enabled then o.HoverSched = SCHEDULER:New(nil, function() o:ScanHoverPickup() end, {}, 0.5, 0.5) end table.insert(CTLD._instances, o) _msgCoalition(o.Side, string.format('CTLD %s initialized for coalition', CTLD.Version)) return o end function CTLD:InitZones() self.PickupZones = {} self.DropZones = {} self.FOBZones = {} self.MASHZones = {} self._ZoneDefs = { PickupZones = {}, DropZones = {}, FOBZones = {}, MASHZones = {} } self._ZoneActive = { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } 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 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 = {} } local found = { Pickup = {}, Drop = {}, FOB = {}, MASH = {} } local coords = { Pickup = 0, Drop = 0, FOB = 0, MASH = 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 -- Log a concise summary to dcs.log local sideStr = sideToStr(self.Side) env.info(string.format('[Moose_CTLD][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)) env.info(string.format('[Moose_CTLD][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)) env.info(string.format('[Moose_CTLD][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)) env.info(string.format('[Moose_CTLD][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)) local anyMissing = (#missing.Pickup > 0) or (#missing.Drop > 0) or (#missing.FOB > 0) or (#missing.MASH > 0) if anyMissing then if #missing.Pickup > 0 then local msg = 'CTLD config warning: Missing Pickup Zones: '..join(missing.Pickup) _msgCoalition(self.Side, msg); env.info('[Moose_CTLD][ZoneValidation]['..sideStr..'] '..msg) end if #missing.Drop > 0 then local msg = 'CTLD config warning: Missing Drop Zones: '..join(missing.Drop) _msgCoalition(self.Side, msg); env.info('[Moose_CTLD][ZoneValidation]['..sideStr..'] '..msg) end if #missing.FOB > 0 then local msg = 'CTLD config warning: Missing FOB Zones: '..join(missing.FOB) _msgCoalition(self.Side, msg); env.info('[Moose_CTLD][ZoneValidation]['..sideStr..'] '..msg) end if #missing.MASH > 0 then local msg = 'CTLD config warning: Missing MASH Zones: '..join(missing.MASH) _msgCoalition(self.Side, msg); env.info('[Moose_CTLD][ZoneValidation]['..sideStr..'] '..msg) end else env.info(string.format('[Moose_CTLD][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) local selfref = self 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 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 env.info('[Moose_CTLD] 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) -- 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 60)) 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 60 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 60 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, '- Fly to location, 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, '- Fly loaded crew to any MASH zone (fixed or mobile).') table.insert(lines, '- 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 -> Build local buildRoot = MENU_GROUP:New(group, 'Build', opsRoot) CMD('Build Here', buildRoot, function() self:BuildAtGroup(group) end) local buildAdvRoot = MENU_GROUP:New(group, 'Build (Advanced)', buildRoot) -- Buildable Near You (dynamic) lives directly under Build self:_BuildOrRefreshBuildAdvancedMenu(group, buildRoot) -- Refresh Buildable List (refreshes the list under Build) MENU_GROUP_COMMAND:New(group, 'Refresh Buildable List', buildRoot, function() self:_BuildOrRefreshBuildAdvancedMenu(group, buildRoot) MESSAGE:New('Buildable list refreshed.', 6):ToGroup(group) end) -- Operations -> MEDEVAC if CTLD.MEDEVAC and CTLD.MEDEVAC.Enabled then local medevacRoot = MENU_GROUP:New(group, 'MEDEVAC', opsRoot) -- 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) -- Admin/Settings submenu local medevacAdminRoot = MENU_GROUP:New(group, 'Admin/Settings', medevacRoot) CMD('Clear All MEDEVAC Missions', medevacAdminRoot, function() self:ClearAllMEDEVACMissions(group) end) end -- Logistics -> Request Crate and Recipe Info local reqRoot = MENU_GROUP:New(group, 'Request Crate', logRoot) local infoRoot = MENU_GROUP:New(group, 'Recipe Info', logRoot) if self.Config.UseCategorySubmenus then local submenus = {} local function getSubmenu(catLabel) if not submenus[catLabel] then submenus[catLabel] = MENU_GROUP:New(group, catLabel, reqRoot) end return submenus[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 for key,def in pairs(self.Config.CrateCatalog) do local label = self:_formatMenuLabelWithCrates(key, def) local sideOk = (not def.side) or def.side == self.Side if sideOk then local catLabel = (def and def.menuCategory) or 'Other' local parent = getSubmenu(catLabel) if def and type(def.requires) == 'table' then -- Composite recipe: request full bundle of component crates CMD(label, parent, function() self:RequestRecipeBundleForGroup(group, key) end) else CMD(label, parent, 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 else for key,def in pairs(self.Config.CrateCatalog) do local label = self:_formatMenuLabelWithCrates(key, def) local sideOk = (not def.side) or def.side == self.Side if sideOk then if def and type(def.requires) == 'table' then CMD(label, reqRoot, function() self:RequestRecipeBundleForGroup(group, key) end) else CMD(label, reqRoot, function() self:RequestCrateForGroup(group, key) end) end CMD(((def and (def.menu or def.description)) or key)..' (info)', infoRoot, function() local text = self:_formatRecipeInfo(key, def) _msgGroup(group, text) end) end end end -- Logistics -> Crate Management 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) 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 zdef = { smoke = self.Config.PickupZoneSmokeColor } local sx, sz = bestMeta.point.x, bestMeta.point.z local sy = 0 if land and land.getHeight then -- land.getHeight expects Vec2 where y is z local ok, h = pcall(land.getHeight, { x = sx, y = sz }) if ok and type(h) == 'number' then sy = h end end -- Use new smoke helper with crate ID for refresh scheduling local smokeColor = (zdef and zdef.smoke) or 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) -- 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) 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) 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)', 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 end return {} end local bestKind, bestZone, bestDist for _, k in ipairs({ 'Pickup', 'Drop', 'FOB' }) 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 (fallbacks if not configured) local color = (bestKind == 'Pickup' and (self.Config.PickupZoneSmokeColor or trigger.smokeColor.Green)) or (bestKind == 'Drop' and trigger.smokeColor.Blue) or trigger.smokeColor.White if trigger and trigger.action and trigger.action.smoke then trigger.action.smoke(center, color) _msgGroup(group, string.format('Smoked nearest %s zone: %s', bestKind, bestZone:GetName())) else _msgGroup(group, 'Smoke not available in this environment.') 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 ipairs(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 ipairs(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 logging', debugMenu, function() self.Config.Debug = true env.info(string.format('[Moose_CTLD][%s] Debug ENABLED via Admin menu', tostring(self.Side))) MESSAGE:New('CTLD Debug logging ENABLED', 8):ToGroup(group) end) CMD('Disable logging', debugMenu, function() self.Config.Debug = false env.info(string.format('[Moose_CTLD][%s] Debug DISABLED via Admin menu', tostring(self.Side))) MESSAGE:New('CTLD Debug logging DISABLED', 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 timer.scheduleFunction(function() self:_BuildOrRefreshInStockMenu(group) end, {}, timer.getTime() + 0.1) 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 60 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 env.info('[Moose_CTLD] 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) -- Hold Position CMD('Build [Hold Position]', 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(string.format('Build [Attack (%dm)]', vr), 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 -- Reuse Build cooldown/confirm logic 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 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 60 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 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 -- 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 60) * 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 if type(def.requires) == 'table' then 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 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' }); return end for reqKey,qty in pairs(def.requires) do consumeCrates(reqKey, 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 pcall(function() self:_CreateMobileMASH(g, { x = spawnAt.x, z = spawnAt.z }, def) end) end -- 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 if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end return else -- single-key local need = def.required or 1 if (counts[recipeKey] or 0) < need then _eventSend(self, group, nil, 'build_insufficient_crates', { build = def.description or recipeKey }); return end 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' }); return end consumeCrates(recipeKey, 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 if self.Config.BuildCooldownEnabled then CTLD._buildCooldown[gname] = now end return 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 60)) 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 60 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 60 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) MENU_COALITION_COMMAND:New(self.Side, 'Enable CTLD Debug Logging', root, function() self.Config.Debug = true env.info(string.format('[Moose_CTLD][%s] Debug ENABLED via Admin menu', tostring(self.Side))) _msgCoalition(self.Side, 'CTLD Debug logging ENABLED', 8) end) MENU_COALITION_COMMAND:New(self.Side, 'Disable CTLD Debug Logging', root, function() self.Config.Debug = false env.info(string.format('[Moose_CTLD][%s] Debug DISABLED via Admin menu', tostring(self.Side))) _msgCoalition(self.Side, 'CTLD Debug logging DISABLED', 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 60 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) 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 zone, dist = self:_nearestActivePickupZone(unit) local defs = self:_collectActivePickupDefs() local hasPickupZones = (#defs > 0) local spawnPoint local maxd = (self.Config.PickupZoneMaxDistance or 10000) -- Announce request 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 if zone and dist and dist <= maxd then -- Compute a random spawn point within the pickup zone to avoid stacking crates local center = zone:GetPointVec3() local rZone = self:_getZoneRadius(zone) local edgeBuf = math.max(0, self.Config.PickupZoneSpawnEdgeBuffer or 10) local minOff = math.max(0, self.Config.PickupZoneSpawnMinOffset or 5) local rMax = math.max(0, (rZone or 150) - edgeBuf) local tries = math.max(1, self.Config.CrateSpawnSeparationTries or 6) local minSep = math.max(0, self.Config.CrateSpawnMinSeparation or 7) local function candidate() 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 minSep <= 0 then return true end for _,meta in pairs(CTLD._crates) do if 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 isClear(chosen) then for _=1,tries-1 do local c = candidate() if isClear(c) then chosen = c; break end end end spawnPoint = { x = chosen.x, z = chosen.z } -- (Smoke will be spawned after crate creation so we can pass the crate ID for refresh scheduling) else -- Either require a pickup zone proximity, or fallback to near-aircraft spawn (legacy behavior) if self.Config.RequirePickupZoneForCrateRequest then local isMetric = _getPlayerIsMetric(unit) local v, u = _fmtRange(math.max(0, dist - 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 -- fallback: spawn near aircraft current position (safe offset) local p = unit:GetPointVec3() spawnPoint = POINT_VEC3:New(p.x + 10, p.y, p.z + 10) end end -- Enforce per-location inventory before spawning 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 -- Try salvage system if enabled if self:_TryUseSalvageForCrate(group, crateKey, cat) then -- Salvage used successfully, continue with crate spawn env.info(string.format('[Moose_CTLD][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(), } -- Now that crate is created, spawn smoke with refresh scheduling if enabled if zone then local zdef = self._ZoneDefs.PickupZones[zone:GetName()] 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 -- Pass crate ID for smoke refresh scheduling _spawnCrateSmoke({ x = sx, y = sy, z = sz }, smokeColor, self.Config.CrateSmoke, cname) end end -- Immersive spawn message with bearing/range per player units 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 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 hasPickupZones = (#self:_collectActivePickupDefs() > 0) 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 -- Spawn each required component crate with separate requests (these handle stock decrement + placement) for reqKey, qty in pairs(def.requires) do local n = tonumber(qty or 0) or 0 for _=1,n do self:RequestCrateForGroup(group, reqKey) end 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 CTLD._crates[name] = nil if self.Config.Debug then env.info('[CTLD] Cleaned up crate '..name) end -- 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 CTLD._deployedTroops[troopGroupName] = nil if self.Config.Debug then env.info('[CTLD] Cleaned up deployed troop group: '..troopGroupName) end end end 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 -- 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 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 -- 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 local gdata = cat.build({ x = spawnAt.x, z = spawnAt.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 for reqKey,qty in pairs(cat.requires) do consumeCrates(reqKey, qty) end -- 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 = spawnAt.x, z = spawnAt.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(), spawnAt, true) local isMetric = _getPlayerIsMetric(group:GetUnit(1)) if t and t.kind == 'base' then local brg = _bearingDeg({ x = spawnAt.x, z = spawnAt.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 = spawnAt.x, z = spawnAt.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 return 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 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 local gdata = cat.build({ x = spawnAt.x, z = spawnAt.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 consumeCrates(key, cat.required or 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(), spawnAt, true) local isMetric = _getPlayerIsMetric(group:GetUnit(1)) if t and t.kind == 'base' then local brg = _bearingDeg({ x = spawnAt.x, z = spawnAt.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 = spawnAt.x, z = spawnAt.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 return 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 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) end function CTLD:DropLoadedCrates(group, howMany) 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 toDrop = math.min(requested, initialTotal) _eventSend(self, group, nil, 'drop_initiated', { count = toDrop }) -- 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 in key order for k,count in pairs(DeepCopy(lc.byKey)) do if toDrop <= 0 then break end local dropNow = math.min(count, toDrop) 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 } } 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 toDrop = toDrop - 1 if toDrop <= 0 then break end end end local actualDropped = initialTotal - (lc.total or 0) _eventSend(self, group, nil, 'dropped_crates', { count = actualDropped }) -- Update DCS internal cargo weight after dropping self:_updateCargoWeight(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) -- 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 } -- find nearest crate within search distance local bestName, bestMeta, bestd local bestType = 'crate' -- Track whether we found a crate or troops local maxd = coachCfg.autoPickupDistance or 25 for name,meta in pairs(CTLD._crates) 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 <= maxd and ((not bestd) or d < bestd) then bestName, bestMeta, bestd = name, meta, d bestType = 'crate' end end end -- Also scan for deployed troop groups to pick up for troopGroupName, troopMeta in pairs(CTLD._deployedTroops) do if troopMeta.side == self.Side then local troopGroup = GROUP:FindByName(troopGroupName) -- Only allow pickup if group exists and is alive 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 CTLD._deployedTroops[troopGroupName] = nil end end end -- Resolve per-group coach enable override 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 isMetric = _getPlayerIsMetric(unit) -- Arrival phase if bestd <= (coachCfg.thresholds.arrivalDist or 1000) then _coachSend(self, group, uname, 'coach_arrival', {}, false) end -- Close-in if bestd <= (coachCfg.thresholds.closeDist or 100) then _coachSend(self, group, uname, 'coach_close', {}, false) end -- Precision phase if bestd <= (coachCfg.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 = coachCfg.thresholds.aglMin or 5 local aglMax = coachCfg.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 = coachCfg.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 local obj = StaticObject.getByName(bestName) if obj then obj:destroy() end _cleanupCrateSmoke(bestName) -- Clean up smoke refresh schedule CTLD._crates[bestName] = nil self:_addLoadedCrate(group, bestMeta.key) if coachEnabled then _coachSend(self, group, uname, 'coach_loaded', {}, false) else _msgGroup(group, string.format('Loaded %s crate', tostring(bestMeta.key))) end elseif bestType == 'troops' then -- Pick up the troop group local troopGroup = GROUP:FindByName(bestName) if troopGroup then troopGroup:Destroy() end 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 } _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 } 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 -- #endregion Hover pickup scanner -- ========================= -- Troops -- ========================= -- #region Troops 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 -- Enforce pickup zone requirement for troop loading (inside zone) 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, } _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, } _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 medevacDelivered = self:CheckMEDEVACDelivery(group, load) if medevacDelivered then -- Crew delivered to MASH, clear troops and return CTLD._troopsLoaded[gname] = nil -- Update DCS internal cargo weight after delivery self:_updateCargoWeight(group) 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' } CTLD._troopsLoaded[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 if behavior == 'attack' and self.Config.AttackAI and self.Config.AttackAI.Enabled then 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 {} -- Debug: Log if troop types are missing if self.Config.Debug and (not def or not def.size) then env.info(string.format('[Moose_CTLD] 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 if self.Config.Debug then local unitList = {} for i=1,math.min(size, 3) do table.insert(unitList, pool[((i-1) % #pool) + 1]) end env.info(string.format('[Moose_CTLD] Spawning %d troops for type "%s": %s%s', size, typeKey or 'AS', table.concat(unitList, ', '), size > 3 and '...' or '')) end 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))) end -- #endregion Inventory helpers -- ========================= -- 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 env.info(string.format('[Moose_CTLD][MEDEVAC] Invulnerable crew member %s killed, respawning...', unitName)) -- Respawn this crew member 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 }) env.info(string.format('[Moose_CTLD][MEDEVAC] Respawned invulnerable crew member %s', unitName)) end end, nil, timer.getTime() + 1) return -- Don't process as normal death end end end end end -- Normal death processing for vehicle spawning MEDEVAC crews if not unit then env.info('[Moose_CTLD][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 env.info('[Moose_CTLD][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 env.info('[Moose_CTLD][MEDEVAC] OnEventDead: Could not determine coalition') return end if unitCoalition ~= selfref.Side then env.info(string.format('[Moose_CTLD][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 env.info('[Moose_CTLD][MEDEVAC] OnEventDead: Could not determine category') return end if unitCategory ~= Unit.Category.GROUND_UNIT then env.info(string.format('[Moose_CTLD][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 env.info('[Moose_CTLD][MEDEVAC] OnEventDead: Could not determine unit type') return end env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][MEDEVAC] OnEventDead: %s found in catalog but MEDEVAC=%s', unitType, tostring(catalogEntry.MEDEVAC))) else env.info(string.format('[Moose_CTLD][MEDEVAC] OnEventDead: %s not found in catalog', unitType)) 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 env.info(string.format('[Moose_CTLD][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() selfref:_CheckMEDEVACTimeouts() end, {}, 30, 30) -- Initialize MASH zones from config self:_InitMASHZones() env.info('[Moose_CTLD] 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 env.info(string.format('[Moose_CTLD][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] env.info(string.format('[Moose_CTLD][MEDEVAC] Catalog entry %s has unitType=%s (from global lookup)', key, tostring(buildUnitType))) if buildUnitType == unitType then env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][MEDEVAC] Found catalog entry for %s via string search: key=%s', unitType, key)) return def end else env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][MEDEVAC] Found catalog entry for %s via def.unitType field: key=%s', unitType, key)) return def end end env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][MEDEVAC] Crew did not survive (roll: %.4f > %.4f)', roll, survivalChance)) return end env.info(string.format('[Moose_CTLD][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 env.info('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][MEDEVAC] Crew will spawn in %d seconds after battle clears', spawnDelay)) 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') env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info('[Moose_CTLD][MEDEVAC] Failed to spawn crew') return end -- Double-check what coalition the spawned group actually belongs to local spawnedCoalition = crewGroup:getCoalition() env.info(string.format('[Moose_CTLD][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) env.info('[Moose_CTLD][MEDEVAC] Crew set to immortal during announcement delay') end if cfg.CrewInvisibleDuringDelay then local setInvisible = { id = 'SetInvisible', params = { value = true } } Controller.setCommand(crewController, setInvisible) env.info('[Moose_CTLD][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 = 0, invulnerable = false, invulnerableUntil = 0, greetingSent = false } CTLD._medevacCrews[crewGroupName] = crewData -- Wait before announcing mission (verify crew survival) local announceDelay = cfg.CrewAnnouncementDelay or 60 env.info(string.format('[Moose_CTLD][MEDEVAC] Will announce mission in %d seconds if crew survives', announceDelay)) timer.scheduleFunction(function() -- Check if crew still exists local g = Group.getByName(crewGroupName) if not g or not g:isExist() then env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][MEDEVAC] Crew %s survived, announcing mission', crewGroupName)) -- Make crew visible again (remove invisibility) and optionally remove immortality local crewController = g:getController() if crewController then -- Always make crew visible when they announce if cfg.CrewInvisibleDuringDelay then local setVisible = { id = 'SetInvisible', params = { value = false } } Controller.setCommand(crewController, setVisible) env.info('[Moose_CTLD][MEDEVAC] Crew is now visible to AI') end -- 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) env.info('[Moose_CTLD][MEDEVAC] Crew immortality removed, now vulnerable') elseif cfg.CrewImmortalAfterAnnounce then env.info('[Moose_CTLD][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) env.info(string.format('[Moose_CTLD][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) end, nil, timer.getTime() + spawnDelay) 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 local remaining = (cfg.CrewTimeout or 3600) - elapsed -- Check for approaching rescue helos (pop smoke and send greeting) if cfg.PopSmokeOnApproach and not data.greetingSent then local approachDist = cfg.PopSmokeOnApproachDistance or 5000 local crewPos = data.position -- 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) data.greetingSent = true env.info(string.format('[Moose_CTLD][MEDEVAC] Crew %s detected helo at %.0fm, popped smoke and sent greeting', crewGroupName, dist)) break end end end end end end end if data.greetingSent then break end end end end -- 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 -- 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 env.info(string.format('[Moose_CTLD][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 -- 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 -- Message to player _msgGroup(rescueGroup, _fmtTemplate(CTLD.Messages.medevac_crew_loaded, { vehicle = crewData.vehicleType, crew_size = crewData.crewSize }), 10) -- 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 timer.scheduleFunction(function() self:_RespawnMEDEVACVehicle(crewData) end, nil, timer.getTime() + 2) -- 2 second delay for realism end -- Mark crew as picked up (for MASH delivery tracking) crewData.pickedUp = true crewData.rescueGroup = rescueGroup:GetName() env.info(string.format('[Moose_CTLD][MEDEVAC] Crew %s picked up by %s', crewGroupName, rescueGroup:GetName())) 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 for key, def in pairs(self.Config.CrateCatalog or {}) do if def.MEDEVAC and tostring(def.build):find(crewData.vehicleType, 1, true) then catalogEntry = def break end end if not catalogEntry or not catalogEntry.build then env.info('[Moose_CTLD][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 env.info('[Moose_CTLD][MEDEVAC] Failed to generate group data for: '..crewData.vehicleType) return end local newGroup = coalition.addGroup(self.Side, Group.Category.GROUND, groupData) if newGroup then _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 env.info(string.format('[Moose_CTLD][MEDEVAC] Respawned %s at %.0f, %.0f', crewData.vehicleType, respawnPos.x, respawnPos.z)) else env.info('[Moose_CTLD][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 -- Check if any picked-up crews match this group local deliveredCrews = {} for crewGroupName, data in pairs(CTLD._medevacCrews) do if data.side == self.Side and data.pickedUp and data.rescueGroup == group:GetName() then table.insert(deliveredCrews, {name = crewGroupName, data = data}) end end if #deliveredCrews == 0 then return false end -- Check if inside MASH zone local unit = group:GetUnit(1) if not unit or not unit:IsAlive() then return false end local pos = unit:GetPointVec3() local inMASH, mashZone = self:_IsPositionInMASHZone({x = pos.x, z = pos.z}) if not inMASH then return false end -- Deliver all picked-up crews for _, crew in ipairs(deliveredCrews) do self:_DeliverMEDEVACCrewToMASH(group, crew.name, crew.data) end return true 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 _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) -- 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 env.info(string.format('[Moose_CTLD][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 local salvageCost = (catalogEntry and catalogEntry.salvageValue) or 0 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 _msgGroup(group, _fmtTemplate(CTLD.Messages.medevac_salvage_insufficient, { need = salvageCost, have = available })) 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] })) env.info(string.format('[Moose_CTLD][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 local salvageCost = ((catalogEntry and catalogEntry.salvageValue) or 0) * quantity if salvageCost <= 0 then return false end local available = CTLD._salvagePoints[self.Side] or 0 return available >= salvageCost 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 zone = mashData.zone if zone then local zonePos = zone:GetPointVec3() 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 env.info('[Moose_CTLD][DEBUG] _InitMASHZones called for coalition '..tostring(self.Side)) env.info('[Moose_CTLD][DEBUG] self.MASHZones count: '..tostring(#(self.MASHZones or {}))) env.info('[Moose_CTLD][DEBUG] 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 } env.info('[Moose_CTLD][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 -- Vectors to nearest MEDEVAC 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 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 dx = nearest.position.x - pos.x local dz = nearest.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 local distKm = nearestDist / 1000 local distNm = nearestDist / 1852 local lines = {} table.insert(lines, string.format('MEDEVAC VECTORS: %s crew', nearest.vehicleType)) table.insert(lines, string.format('Bearing: %03d°', math.floor(bearing + 0.5))) table.insert(lines, string.format('Relative: %+.0f°', relativeBrg)) table.insert(lines, string.format('Range: %.1f km / %.1f nm', distKm, distNm)) _msgGroup(group, table.concat(lines, '\n'), 15) 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 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 = self:_GetMGRSString(position) local typeStr = data.isMobile and 'Mobile' or 'Fixed' local radius = tonumber(data.radius) or 500 table.insert(lines, string.format('%d. MASH %s (%s)', count, name, typeStr)) table.insert(lines, string.format(' Grid: %s', grid)) table.insert(lines, string.format(' Radius: %d m', radius)) if playerPos and position then local dist = math.sqrt((position.x - playerPos.x)^2 + (position.z - playerPos.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) env.info('[Moose_CTLD][MEDEVAC] PopSmokeAtMEDEVACSites called') local cfg = CTLD.MEDEVAC if not cfg or not cfg.Enabled then env.info('[Moose_CTLD][MEDEVAC] MEDEVAC system not enabled') _msgGroup(group, 'MEDEVAC system is not enabled.') return end if not CTLD._medevacCrews then env.info('[Moose_CTLD][MEDEVAC] No _medevacCrews table') _msgGroup(group, 'No active MEDEVAC requests to mark with smoke.') return end local count = 0 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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 env.info(string.format('[Moose_CTLD][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) env.info('[Moose_CTLD][MEDEVAC] PopSmokeAtMASHZones called') local cfg = CTLD.MEDEVAC if not cfg or not cfg.Enabled then env.info('[Moose_CTLD][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 = (cfg.SmokeColor and cfg.SmokeColor[self.Side]) or trigger.smokeColor.Green 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) env.info(string.format('[Moose_CTLD][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) env.info(string.format('[Moose_CTLD][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 return end if not cfg.MobileMASH or not cfg.MobileMASH.Enabled then return end local side = catalogDef.side or self.Side if not CTLD._mobileMASHCounter then CTLD._mobileMASHCounter = {} end if not CTLD._mobileMASHCounter[side] then CTLD._mobileMASHCounter[side] = 0 end CTLD._mobileMASHCounter[side] = CTLD._mobileMASHCounter[side] + 1 local mashId = string.format('MOBILE_MASH_%d_%d', side, CTLD._mobileMASHCounter[side]) local radius = cfg.MobileMASH.ZoneRadius or 500 -- Register the MASH zone local mashData = { id = mashId, position = {x = position.x, z = position.z}, radius = radius, side = side, group = group, isMobile = true, catalogKey = catalogDef.description or 'Mobile MASH' } if not CTLD._mashZones then CTLD._mashZones = {} end table.insert(CTLD._mashZones, mashData) -- Draw on F10 map local circleId = mashId .. '_circle' local textId = mashId .. '_text' local p = {x = position.x, y = 0, z = position.z} local borderColor = cfg.MASHZoneColors.border or {1, 1, 0, 0.85} local fillColor = cfg.MASHZoneColors.fill or {1, 0.75, 0.8, 0.25} trigger.action.circleToCoalition(side, circleId, p, radius, borderColor, fillColor, 1, true, "") local label = string.format('Mobile MASH %d', CTLD._mobileMASHCounter[side]) 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, label) mashData.circleId = circleId mashData.textId = textId -- Send initial deployment message local gridStr = self:_GetMGRSString(position) local msg = _fmtMsg(CTLD.Messages.medevac_mash_deployed, { mash_id = CTLD._mobileMASHCounter[side], grid = gridStr, freq = cfg.MobileMASH.BeaconFrequency or '30.0 FM' }) trigger.action.outTextForCoalition(side, msg, 30) env.info(string.format('[Moose_CTLD][MobileMASH] Deployed MASH %d at %s', CTLD._mobileMASHCounter[side], gridStr)) -- Start announcement scheduler if cfg.MobileMASH.AnnouncementInterval and cfg.MobileMASH.AnnouncementInterval > 0 then local ctldInstance = self local scheduler = SCHEDULER:New(nil, function() -- Check if group still exists if not group or not group:IsAlive() then ctldInstance:_RemoveMobileMASH(mashId) return end -- Send periodic announcement local currentPos = group:GetCoordinate() if currentPos then local currentGrid = ctldInstance:_GetMGRSString({x = currentPos.x, z = currentPos.z}) local announceMsg = _fmtMsg(CTLD.Messages.medevac_mash_announcement, { mash_id = CTLD._mobileMASHCounter[side], grid = currentGrid, freq = cfg.MobileMASH.BeaconFrequency or '30.0 FM' }) trigger.action.outTextForCoalition(side, announceMsg, 20) end end, {}, cfg.MobileMASH.AnnouncementInterval, cfg.MobileMASH.AnnouncementInterval) mashData.scheduler = scheduler end -- Set up death event handler for this specific MASH local ctldInstance = self local mashGroupName = group:GetName() local eventHandler = EVENTHANDLER:New() eventHandler:HandleEvent(EVENTS.Dead) function eventHandler:OnEventDead(EventData) if EventData.IniUnit and EventData.IniGroup then local deadGroup = EventData.IniGroup if deadGroup:GetName() == mashGroupName then ctldInstance:_RemoveMobileMASH(mashId) end end end mashData.eventHandler = eventHandler end -- Remove a Mobile MASH zone (on destruction or manual removal) function CTLD:_RemoveMobileMASH(mashId) if not CTLD._mashZones then return end for i = #CTLD._mashZones, 1, -1 do local mash = CTLD._mashZones[i] if mash.id == mashId then -- Stop scheduler if mash.scheduler then mash.scheduler: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 -- Send destruction message local msg = _fmtMsg(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 table.remove(CTLD._mashZones, i) env.info(string.format('[Moose_CTLD][MobileMASH] Removed MASH %s', mashId)) break 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 opts = { OutlineColor = md.OutlineColor, FillColor = (md.FillColors and md.FillColors.Drop) or nil, LineType = (md.LineTypes and md.LineTypes.Drop) 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.Drop) or 'Drop Zone', ForAll = (md.ForAll == true), } pcall(function() self:_drawZoneCircleAndLabel('Drop', mz, opts) 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 -- #endregion Public helpers -- ========================= -- Return factory -- ========================= -- #region Export _MOOSE_CTLD = CTLD return CTLD -- #endregion Export