-- 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' -- Immersive Hover Coach configuration (messages, thresholds, throttling) -- All user-facing text lives here; logic only fills placeholders. -- #region Messaging CTLD.HoverCoachConfig = { enabled = true, -- master switch for hover coaching messages coachOnByDefault = true, -- future per-player toggle; currently always on when enabled thresholds = { arrivalDist = 1000, -- m: start guidance “You’re close…” closeDist = 100, -- m: reduce speed / set AGL guidance precisionDist = 10, -- m: start precision hints captureHoriz = 4, -- m: horizontal sweet spot radius captureVert = 4, -- 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 = 1.5, -- m/s: absolute vertical speed during capture driftResetDist = 20, -- m: if beyond, reset precision phase stabilityHold = 1.8 -- s: hold steady before loading }, throttle = { coachUpdate = 2, -- s between hint updates in precision generic = 3.0, -- s between non-coach messages repeatSame = 6.0 -- s before repeating same message key }, } -- 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_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.", 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}°.", -- 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.", } -- #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' }, UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide 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 = 5000, -- default spawn altitude ASL (meters) SpeedMps = 120 -- default initial speed in m/s }, DropCrateForwardOffset = 20, -- 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, -- 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 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 = 10000, -- 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 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 }, 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 }, -- 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', } }, -- 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.25, -- 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 }, -- Hover pickup configuration (Ciribob-style inspired) HoverPickup = { Enabled = true, -- if true, auto-load the nearest crate when hovering close enough for a duration Height = 3, -- legacy: meters AGL threshold for hover pickup (superseded by HoverCoach thresholds when coach enabled) Radius = 15, -- meters horizontal distance to crate to consider for pickup (used if precision thresholds not applicable) AutoPickupDistance = 25, -- meters max search distance for candidate crates Duration = 3, -- seconds of continuous hover before loading occurs (steady time) MaxCratesPerLoad = 6, -- maximum crates the aircraft can carry simultaneously RequireLowSpeed = true, -- require near-stationary hover MaxSpeedMPS = 5 -- max allowed speed in m/s for hover pickup }, -- Troop type presets (menu-driven loadable teams) Troops = { -- Default troop type to use when no specific type is chosen DefaultType = 'AS', -- Team definitions: label (menu text), size (number spawned), and unit pools per coalition -- NOTE: Unit type names are DCS database strings. The provided defaults are conservative and -- use generic infantry to maximize compatibility. You can customize per mission/era. TroopTypes = { -- Assault squad: general-purpose rifles/MG AS = { label = 'Assault Squad', size = 8, -- Fallback pools; adjust for era/faction if you want richer mixes unitsBlue = { 'Infantry M4', 'Infantry M249' }, unitsRed = { 'Infantry AK', 'Infantry AK' }, -- If specific Blue/Red not available, this generic pool is used units = { 'Infantry AK' }, }, -- Anti-air team: MANPADS element AA = { label = 'MANPADS Team', size = 4, -- These names vary by mod/DB; defaults fall back to generic infantry if unavailable unitsBlue = { 'Infantry manpad Stinger', 'Infantry M4' }, unitsRed = { 'Infantry manpad Igla', 'Infantry AK' }, units = { 'Infantry AK' }, }, -- Anti-tank team: RPG/AT4 element AT = { label = 'AT Team', size = 4, unitsBlue = { 'Soldier M136', 'Infantry M4' }, unitsRed = { 'Soldier RPG', 'Infantry AK' }, units = { 'Infantry AK' }, }, -- Indirect fire: mortar detachment AR = { label = 'Mortar Team', size = 4, unitsBlue = { 'Mortar M252' }, unitsRed = { '2B11 mortar' }, units = { 'Infantry AK' }, }, }, }, } -- #endregion Config -- #region State -- Internal state tables CTLD._instances = CTLD._instances or {} CTLD._crates = {} -- [crateName] = { key, zone, side, spawnTime, point } CTLD._troopsLoaded = {} -- [groupName] = { count, typeKey } CTLD._loadedCrates = {} -- [groupName] = { total=n, byKey = { key -> count } } 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 -- #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 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) -- Enforce side/category in groupData just to be safe groupData.category = category 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 -- Unique id generator for map markups (lines/circles/text) local function _nextMarkupId() CTLD._NextMarkupId = (CTLD._NextMarkupId or 10000) + 1 return CTLD._NextMarkupId 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] 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' 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 = {} } 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') and kind or nil if not k then return end self._ZoneActive = self._ZoneActive or { Pickup = {}, Drop = {}, FOB = {} } 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 {} 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 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 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 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) 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 -- Periodic cleanup for crates o.Sched = SCHEDULER:New(nil, function() o:CleanupCrates() end, {}, 60, 60) -- 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 if o.Config.HoverPickup and o.Config.HoverPickup.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._ZoneDefs = { PickupZones = {}, DropZones = {}, FOBZones = {} } self._ZoneActive = { Pickup = {}, Drop = {}, FOB = {} } 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 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 = {} } local found = { Pickup = {}, Drop = {}, FOB = {} } local coords = { Pickup = 0, Drop = 0, FOB = 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 -- 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)) local anyMissing = (#missing.Pickup > 0) or (#missing.Drop > 0) or (#missing.FOB > 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 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 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, '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, '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 -- 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 hp = self.Config.HoverPickup or {} local height = hp.Height or 5 local spd = hp.MaxSpeedMPS or 5 local dur = hp.Duration or 3 local lines = {} table.insert(lines, 'Hover Pickup & Slingloading') table.insert(lines, '') table.insert(lines, string.format('- Hover pickup: hold AGL ~%d m, speed < %d m/s, for ~%d s to auto-load.', height, spd, dur)) 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) -- 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) -- 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 trigger.action.smoke({ x = sx, y = sy, z = sz }, (zdef and zdef.smoke) or self.Config.PickupZoneSmokeColor) _eventSend(self, group, nil, 'crate_re_marked', { id = bestName, mark = 'smoke' }) else _msgGroup(group, 'No friendly crates found to mark.') end 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('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) 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) -- 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) 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) -- 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 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) 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) 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 -- 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) 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 hp = self.Config.HoverPickup or {} local height = hp.Height or 5 local spd = hp.MaxSpeedMPS or 5 local dur = hp.Duration or 3 local lines = {} table.insert(lines, 'Hover Pickup & Slingloading') table.insert(lines, '') table.insert(lines, string.format('- Hover pickup: hold AGL ~%d m, speed < %d m/s, for ~%d s to auto-load.', height, spd, dur)) 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 } -- if pickup zone has smoke configured, mark the spawn location 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 trigger.action.smoke({ x = sx, y = sy, z = sz }, smokeColor) end 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 _msgGroup(group, string.format('Out of stock at %s for %s', zoneNameForStock, self:_friendlyNameForKey(crateKey))) return end CTLD._stockByZone[zoneNameForStock][crateKey] = cur - 1 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(), } -- 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 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 _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 -- 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 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 -- #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 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) 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) 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 function CTLD:_addLoadedCrate(group, crateKey) local gname = group:GetName() CTLD._loadedCrates[gname] = CTLD._loadedCrates[gname] or { total = 0, byKey = {} } local lc = CTLD._loadedCrates[gname] lc.total = lc.total + 1 lc.byKey[crateKey] = (lc.byKey[crateKey] or 0) + 1 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) for i=1,dropNow do local cname = string.format('CTLD_CRATE_%s_%d', k, math.random(100000,999999)) local cat = self.Config.CrateCatalog[k] _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 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 }) -- 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 -- #endregion Loaded crate management -- ========================= -- Hover pickup scanner -- ========================= -- #region Hover pickup scanner function CTLD:ScanHoverPickup() local hp = self.Config.HoverPickup or {} if not hp.Enabled then return end local coachCfg = CTLD.HoverCoachConfig or { enabled = false } -- 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 maxd = hp.AutoPickupDistance or hp.Radius 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 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 (coach or legacy) local speedOK, heightOK if coachCfg.enabled then local capGS = coachCfg.thresholds.captureGS or (4/3.6) local aglMin = coachCfg.thresholds.aglMin or 5 local aglMax = coachCfg.thresholds.aglMax or 20 speedOK = gs <= capGS heightOK = (agl >= aglMin and agl <= aglMax) else speedOK = (not hp.RequireLowSpeed) or (gs <= (hp.MaxSpeedMPS or 5)) heightOK = agl <= (hp.Height or 3) end if bestName and bestMeta and speedOK and heightOK then local withinRadius if coachCfg.enabled then withinRadius = bestd <= (coachCfg.thresholds.captureHoriz or 2) else withinRadius = bestd <= (hp.Radius or hp.AutoPickupDistance or 25) end if withinRadius then local carried = CTLD._loadedCrates[gname] local total = carried and carried.total or 0 if total < (hp.MaxCratesPerLoad or 6) then local hs = CTLD._hoverState[uname] if not hs or hs.targetCrate ~= bestName then CTLD._hoverState[uname] = { targetCrate = bestName, startTime = now } if coachCfg.enabled then _coachSend(self, group, uname, 'coach_hold', {}, false) end else -- stability hold timer local holdNeeded = coachCfg.enabled and (coachCfg.thresholds.stabilityHold or (hp.Duration or 3)) or (hp.Duration or 3) if (now - hs.startTime) >= holdNeeded then -- load it local obj = StaticObject.getByName(bestName) if obj then obj:destroy() end 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 CTLD._hoverState[uname] = nil end end 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 -- 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) CTLD._troopsLoaded[gname] = { count = #unitsList, typeKey = requestedType, } _eventSend(self, group, nil, 'troops_loaded', { count = #unitsList }) 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 -- 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 based on type local comp, _ = self:_resolveTroopUnits(load.typeKey) local units = {} local spacing = 1.8 for i=1, #comp do local dx = (i-1) * spacing local dz = ((i % 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 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) if spawned then CTLD._troopsLoaded[gname] = nil _eventSend(self, nil, self.Side, 'troops_unloaded_coalition', { count = #units, player = _playerNameFromGroup(group) }) -- 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 {} 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 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 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) 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) 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 -- 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 = {} } 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