From c2e549c9c1910513ef4c1f8123328b7e88be665f Mon Sep 17 00:00:00 2001 From: iTracerFacer <134304944+iTracerFacer@users.noreply.github.com> Date: Mon, 10 Nov 2025 07:50:32 -0600 Subject: [PATCH] Reorganized the CTLD configuration block in Moose_CTLD.lua so related settings sit together under clear section headers for mission makers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced an “Instance & Access” section for coalition/aircraft gates, then grouped runtime/logging, menu/catalog, transport capacity, deployment rules, build controls, pickup/drop logic, autonomous assets, combat automation, visual aids, inventory, and zone tables. Kept every existing setting (JTAC, drone spawn, inventory, etc.) while tightening inline comments to explain practical effects without altering defaults. --- Moose_CTLD_Pure/Moose_CTLD.lua | 653 +++++++++++++++--- .../catalogs/Moose_CTLD_Catalog.lua | 8 +- 2 files changed, 571 insertions(+), 90 deletions(-) diff --git a/Moose_CTLD_Pure/Moose_CTLD.lua b/Moose_CTLD_Pure/Moose_CTLD.lua index d6a7edf..3ed5b04 100644 --- a/Moose_CTLD_Pure/Moose_CTLD.lua +++ b/Moose_CTLD_Pure/Moose_CTLD.lua @@ -153,6 +153,12 @@ CTLD.Messages = { attack_base_announce = "{unit_name} deployed by {player} is moving to capture {base_name} at {brg}°, {rng} {rng_u}.", attack_no_targets = "{unit_name} deployed by {player} found no targets within {rng} {rng_u}. Holding position.", + jtac_onstation = "JTAC {jtac} on station. CODE {code}.", + jtac_new_target = "JTAC {jtac} lasing {target}. CODE {code}. POS {grid}.", + jtac_target_lost = "JTAC {jtac} lost target. Reacquiring.", + jtac_target_destroyed = "JTAC {jtac} reports target destroyed.", + jtac_idle = "JTAC {jtac} scanning for targets.", + -- Zone restrictions drop_forbidden_in_pickup = "Cannot drop crates inside a Supply Zone. Move outside the zone boundary.", troop_deploy_forbidden_in_pickup = "Cannot deploy troops inside a Supply Zone. Move outside the zone boundary.", @@ -185,20 +191,39 @@ CTLD.Messages = { -- #endregion Messaging CTLD.Config = { + -- === Instance & Access === CoalitionSide = coalition.side.BLUE, -- default coalition this instance serves (menus created for this side) CountryId = nil, -- optional explicit country id for spawned groups; falls back per coalition AllowedAircraft = { -- transport-capable unit type names (case-sensitive as in DCS DB) 'UH-1H','Mi-8MTV2','Mi-24P','SA342M','SA342L','SA342Minigun','Ka-50','Ka-50_3','AH-64D_BLK_II','UH-60L','CH-47Fbl1','CH-47F','Mi-17','GazelleAI' }, - - -- Logging control: set the desired level of detail for env.info logging to DCS.log + -- === Runtime & Messaging === + -- Logging control: set the desired level of detail for env.info logging to DCS.log -- 0 = NONE - No logging at all (production servers) -- 1 = ERROR - Only critical errors and warnings -- 2 = INFO - Important state changes, initialization, cleanup (default for production) -- 3 = VERBOSE - Detailed operational info (zone validation, menus, builds, MEDEVAC events) -- 4 = DEBUG - Everything including hover checks, crate pickups, detailed troop spawns LogLevel = 4, - + MessageDuration = 15, -- seconds for on-screen messages + Debug = false, -- leave false for production; enables extra debug output and draws when true + + -- === Menu & Catalog === + UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide (leave this alone) + CreateMenuAtMissionStart = false, -- creates empty root menu at mission start to reserve F10 position (populated on player spawn) + RootMenuName = 'CTLD', -- name for the root F10 menu; menu ordering depends on script load order in mission editor + UseCategorySubmenus = true, -- if true, organize crate requests by category submenu (menuCategory) + UseBuiltinCatalog = false, -- start with the shipped catalog (true) or expect mission to load its own (false) + + -- === Transport Capacity === + -- Default capacities for aircraft not listed in AircraftCapacities table + -- Used as fallback for any transport aircraft without specific limits defined + DefaultCapacity = { + maxCrates = 4, -- reasonable middle ground + maxTroops = 12, -- moderate squad size + maxWeightKg = 2000, -- default weight capacity in kg (omit to disable weight modeling) + }, + -- Per-aircraft capacity limits (realistic cargo/troop capacities) -- Set maxCrates = 0 and maxTroops = 0 for attack helicopters with no cargo capability -- If an aircraft type is not listed here, it will use DefaultCapacity values @@ -211,21 +236,21 @@ CTLD.Config = { ['SA342L'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, ['SA342Minigun'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, ['GazelleAI'] = { maxCrates = 1, maxTroops = 3, maxWeightKg = 400 }, - + -- Attack Helicopters (no cargo capacity - combat only) ['Ka-50'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark - single seat attack ['Ka-50_3'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Black Shark 3 ['AH-64D_BLK_II'] = { maxCrates = 0, maxTroops = 0, maxWeightKg = 0 }, -- Apache - attack/recon only ['Mi-24P'] = { maxCrates = 2, maxTroops = 8, maxWeightKg = 1000 }, -- Hind - attack helo but has small troop bay - + -- Light Utility Helicopters (moderate capacity) ['UH-1H'] = { maxCrates = 3, maxTroops = 11, maxWeightKg = 1800 }, -- Huey - classic light transport - + -- Medium Transport Helicopters (good capacity) ['Mi-8MTV2'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip - Russian medium transport ['Mi-17'] = { maxCrates = 5, maxTroops = 24, maxWeightKg = 4000 }, -- Hip variant ['UH-60L'] = { maxCrates = 4, maxTroops = 11, maxWeightKg = 4000 }, -- Black Hawk - medium utility - + -- Heavy Lift Helicopters (maximum capacity) ['CH-47Fbl1'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook - heavy lift beast ['CH-47F'] = { maxCrates = 10, maxTroops = 33, maxWeightKg = 12000 }, -- Chinook variant @@ -235,83 +260,103 @@ CTLD.Config = { ['C-130'] = { maxCrates = 20, maxTroops = 92, maxWeightKg = 20000, requireGround = true, maxGroundSpeed = 1.0 }, -- C-130 Hercules - tactical airlifter (must be fully stopped) ['C-17A'] = { maxCrates = 30, maxTroops = 150, maxWeightKg = 77500, requireGround = true, maxGroundSpeed = 1.0 }, -- C-17 Globemaster III - strategic airlifter }, - - -- Default capacities for aircraft not listed in AircraftCapacities table - -- Used as fallback for any transport aircraft without specific limits defined - DefaultCapacity = { - maxCrates = 4, -- reasonable middle ground - maxTroops = 12, -- moderate squad size - maxWeightKg = 2000, -- default weight capacity in kg (omit to disable weight modeling) - }, - - UseGroupMenus = true, -- if true, F10 menus per player group; otherwise coalition-wide - CreateMenuAtMissionStart = false, -- if true with UseGroupMenus=true, creates empty root menu at mission start to reserve F10 position (populated on player spawn) - RootMenuName = 'CTLD', -- Name for the root F10 menu. Note: Menu ordering depends on script load order in mission editor. - UseCategorySubmenus = true, -- if true, organize crate requests by category submenu (menuCategory) - UseBuiltinCatalog = false, -- if false, starts with an empty catalog; intended when you preload a global catalog and want only that - -- Safety offsets to avoid spawning units too close to player aircraft - BuildSpawnOffset = 40, -- meters: shift build point forward from the aircraft to avoid rotor/ground collisions (0 = spawn centered on aircraft) - TroopSpawnOffset = 40, -- meters: shift troop unload point forward from the aircraft - - -- Air-spawn settings for CTLD-built drones (AIRPLANE category entries in the catalog like MQ-9 / WingLoong) - DroneAirSpawn = { - Enabled = true, -- when true, AIRPLANE catalog items that opt-in can spawn in the air at a set altitude - AltitudeMeters = 3048, -- default spawn altitude ASL (meters) - 10,000 feet - SpeedMps = 120 -- default initial speed in m/s - }, - DropCrateForwardOffset = 35, -- meters: drop loaded crates this far in front of the aircraft (instead of directly under) - RestrictFOBToZones = false, -- if true, recipes marked isFOB only build inside configured FOBZones - AutoBuildFOBInZones = false, -- if true, CTLD auto-builds FOB recipes when required crates are inside a FOB zone - BuildRadius = 60, -- meters around build point to collect crates - CrateLifetime = 3600, -- seconds before crates auto-clean up; 0 = disable - MessageDuration = 15, -- seconds for on-screen messages - Debug = false, - - - -- Ground requirements for loading (realistic behavior) - RequireGroundForTroopLoad = true, -- if true, must be landed to load troops (prevents loading while hovering) - RequireGroundForVehicleLoad = true, -- if true, must be landed to load vehicles (C-130/large transports) - MaxGroundSpeedForLoading = 2.0, -- meters/second: max ground speed allowed for loading (prevents loading while taxiing fast; ~4 knots) - + -- === Loading & Deployment Rules === + RequireGroundForTroopLoad = true, -- must be landed to load troops (prevents loading while hovering) + RequireGroundForVehicleLoad = true, -- must be landed to load vehicles (C-130/large transports) + MaxGroundSpeedForLoading = 2.0, -- meters/second limit while loading (roughly 4 knots) + -- Fast-rope deployment (allows troop unload while hovering at safe altitude) EnableFastRope = true, -- if true, troops can fast-rope from hovering helicopters FastRopeMaxHeight = 20, -- meters AGL: maximum altitude for fast-rope deployment FastRopeMinHeight = 5, -- meters AGL: minimum altitude for fast-rope deployment (too low = collision risk) + + -- Safety offsets to avoid spawning units too close to player aircraft + BuildSpawnOffset = 40, -- meters: shift build point forward from the aircraft (0 = spawn centered on aircraft) + TroopSpawnOffset = 40, -- meters: shift troop unload point forward from the aircraft + DropCrateForwardOffset = 35, -- meters: drop loaded crates this far in front of the aircraft + + -- === Build & Crate Handling === + BuildRequiresGroundCrates = true, -- required crates must be on the ground (not still carried) + BuildRadius = 60, -- meters around build point to collect crates + RestrictFOBToZones = false, -- only allow FOB recipes inside configured FOBZones + AutoBuildFOBInZones = false, -- auto-build FOB recipes when required crates are inside a FOB zone + CrateLifetime = 3600, -- seconds before crates auto-clean up; 0 = disable + -- Build safety - BuildConfirmEnabled = false, -- require a second confirmation within a short window before building + 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 + BuildCooldownEnabled = true, -- impose a cooldown before allowing another build by the same group BuildCooldownSeconds = 60, -- seconds of cooldown after a successful build per group + + -- === Pickup & Drop Zone Rules === + RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone + RequirePickupZoneForTroopLoad = true, -- troops can only be loaded while inside a Supply (Pickup) Zone + PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request + ForbidDropsInsidePickupZones = true, -- block crate drops while inside a Pickup Zone + ForbidTroopDeployInsidePickupZones = true, -- block troop deploy while inside a Pickup Zone + ForbidChecksActivePickupOnly = true, -- when true, restriction applies only to ACTIVE pickup zones; false blocks all configured pickup zones + + -- Dynamic Drop Zone settings + DropZoneRadius = 250, -- meters: radius used when creating a Drop Zone via the admin menu at player position + MinDropZoneDistanceFromPickup = 2000, -- meters: minimum distance from nearest Pickup Zone required to create a dynamic Drop Zone (0 to disable) + MinDropDistanceActivePickupOnly = true, -- when true, only ACTIVE pickup zones are considered for the minimum distance check + + -- === Pickup Zone Spawn Placement === + PickupZoneSpawnRandomize = 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 PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones - + -- Crate Smoke Settings -- NOTE: Individual smoke effects last ~5 minutes (DCS hardcoded, cannot be changed) -- These settings control whether/how often NEW smoke is spawned, not how long each smoke lasts CrateSmoke = { - Enabled = true, -- if true, spawn smoke when crates are created; if false, no smoke at all - AutoRefresh = false, -- if true, automatically spawn new smoke every RefreshInterval seconds (creates continuous smoke) - RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true; 240s = 4min recommended) - MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit; 600s = 10min; set high or disable AutoRefresh for one-time smoke) - OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke (0 = directly on crate) + Enabled = true, -- spawn smoke when crates are created; if false, no smoke at all + AutoRefresh = false, -- automatically spawn new smoke every RefreshInterval seconds + RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true) + MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit) + OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke OffsetRandom = true, -- if true, randomize horizontal offset direction; if false, always offset north - OffsetVertical = 20, -- meters: vertical offset above ground level (helps smoke be more visible; 2-3 recommended) + OffsetVertical = 20, -- meters: vertical offset above ground level (helps smoke be more visible) }, - - RequirePickupZoneForCrateRequest = true, -- enforce that crate requests must be near a Supply (Pickup) Zone - RequirePickupZoneForTroopLoad = true, -- if true, troops can only be loaded while inside a Supply (Pickup) Zone - PickupZoneMaxDistance = 10000, -- meters; nearest pickup zone must be within this distance to allow a request - -- Safety rules around Supply (Pickup) Zones - ForbidDropsInsidePickupZones = true, -- if true, players cannot drop crates while inside a Pickup Zone - ForbidTroopDeployInsidePickupZones = true, -- if true, players cannot deploy troops while inside a Pickup Zone - ForbidChecksActivePickupOnly = true, -- when true, restriction applies only to ACTIVE pickup zones; set false to block inside any configured pickup zone - -- Dynamic Drop Zone settings - DropZoneRadius = 250, -- meters: radius used when creating a Drop Zone via the admin menu at player position - MinDropZoneDistanceFromPickup = 2000, -- meters: minimum distance from nearest Pickup Zone required to create a dynamic Drop Zone (0 to disable) - MinDropDistanceActivePickupOnly = true, -- when true, only ACTIVE pickup zones are considered for the minimum distance check - - -- Attack/Defend AI behavior for deployed troops and built vehicles + -- === Autonomous Assets === + -- Air-spawn settings for CTLD-built drones (AIRPLANE catalog entries like MQ-9 / WingLoong) + DroneAirSpawn = { + Enabled = true, -- when true, AIRPLANE catalog items that opt-in can spawn in the air at a set altitude + AltitudeMeters = 5000, -- default spawn altitude ASL (meters) + SpeedMps = 120 -- default initial speed in m/s + }, + + JTAC = { + Enabled = true, + AutoLase = { + Enabled = true, + SearchRadius = 8000, -- meters to scan for enemy targets + RefreshSeconds = 15, -- seconds between active target updates + IdleRescanSeconds = 30, -- seconds between scans when no target locked + LostRetrySeconds = 10, -- wait before trying to reacquire after transport/line-of-sight loss + TransportHoldSeconds = 10, -- defer lase loop while JTAC is in transport (group empty) + }, + Smoke = { + Enabled = true, + ColorBlue = trigger.smokeColor.Orange, + ColorRed = trigger.smokeColor.Green, + RefreshSeconds = 300, -- seconds between smoke refreshes on active targets + OffsetMeters = 5, -- random offset radius for smoke placement + }, + LaserCodes = { '1688','1677','1666','1113','1115','1111' }, + LockType = 'all', -- 'all' | 'vehicle' | 'troop' + Announcements = { + Enabled = true, + Duration = 10, + }, + }, + + -- === Combat Automation === AttackAI = { Enabled = true, -- master switch for attack behavior TroopSearchRadius = 3000, -- meters: when deploying troops with Attack, search radius for targets/bases @@ -320,14 +365,15 @@ CTLD.Config = { TroopAdvanceSpeedKmh = 20, -- movement speed for troops when ordered to attack VehicleAdvanceSpeedKmh = 35, -- movement speed for vehicles when ordered to attack }, - + + -- === Visual Aids === -- Optional: draw zones on the F10 map using trigger.action.* markup (ME Draw-like) MapDraw = { Enabled = true, -- master switch for any map drawings created by this script DrawPickupZones = true, -- draw Pickup/Supply zones as shaded circles with labels - DrawDropZones = true, -- optionally draw Drop zones - DrawFOBZones = true, -- optionally draw FOB zones - DrawMASHZones = true, -- optionally draw MASH (medical) zones + DrawDropZones = true, -- optionally draw Drop zones + DrawFOBZones = true, -- optionally draw FOB zones + DrawMASHZones = true, -- optionally draw MASH (medical) zones FontSize = 18, -- label text size ReadOnly = true, -- prevent clients from removing the shapes ForAll = false, -- if true, draw shapes to all (-1) instead of coalition only (useful for testing/briefing) @@ -360,32 +406,24 @@ CTLD.Config = { } }, - -- 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 & Troops === -- Inventory system (per pickup zone and FOBs) Inventory = { Enabled = true, -- master switch for per-location stock control FOBStockFactor = 0.50, -- starting stock at newly built FOBs relative to pickup-zone initialStock - ShowStockInMenu = true, -- if true, append simple stock hints to menu labels (per current nearest zone) + ShowStockInMenu = true, -- append simple stock hints to menu labels (per current nearest zone) HideZeroStockMenu = false, -- removed: previously created an "In Stock Here" submenu; now disabled by default }, - + -- Troop type presets (menu-driven loadable teams) Troops = { - -- Default troop type to use when no specific type is chosen - DefaultType = 'AS', + DefaultType = 'AS', -- default troop type to use when no specific type is chosen -- Team definitions: loaded from catalog via _CTLD_TROOP_TYPES global -- If no catalog is loaded, empty table is used (and fallback logic applies) TroopTypes = {}, }, - - -- Zones (Supply/Pickup, Drop, FOB, MASH) + + -- === Zone Tables === -- Mission makers should populate these arrays with zone definitions -- Each zone entry can be: { name = 'ZoneName' } or { name = 'ZoneName', flag = 9001, activeWhen = 0, smoke = color, radius = meters } Zones = { @@ -1098,6 +1136,11 @@ CTLD._spatialGridSize = 500 -- meters per grid cell (tunable based on hover pic -- Inventory state CTLD._stockByZone = CTLD._stockByZone or {} -- [zoneName] = { [crateKey] = count } CTLD._inStockMenus = CTLD._inStockMenus or {} -- per-group filtered menu handles +CTLD._jtacReservedCodes = CTLD._jtacReservedCodes or { + [coalition.side.BLUE] = {}, + [coalition.side.RED] = {}, + [coalition.side.NEUTRAL] = {}, +} -- MEDEVAC state CTLD._medevacCrews = CTLD._medevacCrews or {} -- [crewGroupName] = { vehicleType, side, spawnTime, position, salvageValue, markerID, originalHeading, requestTime, warningsSent } CTLD._salvagePoints = CTLD._salvagePoints or {} -- [coalition.side] = points (global pool) @@ -1180,6 +1223,67 @@ local function _isIn(list, value) return false end +local function _vec3(x, y, z) + return { x = x, y = y, z = z } +end + +local function _distance3d(a, b) + if not a or not b then return math.huge end + local dx = (a.x or 0) - (b.x or 0) + local dy = (a.y or 0) - (b.y or 0) + local dz = (a.z or 0) - (b.z or 0) + return math.sqrt(dx * dx + dy * dy + dz * dz) +end + +local function _unitHasAttribute(unit, attr) + if not unit or not attr then return false end + local ok, res = pcall(function() return unit:hasAttribute(attr) end) + return ok and res == true +end + +local function _isDcsInfantry(unit) + if not unit then return false end + local tn = string.lower(unit:getTypeName() or '') + if tn:find('infantry') or tn:find('soldier') or tn:find('paratrooper') or tn:find('manpad') then + return true + end + return _unitHasAttribute(unit, 'Infantry') +end + +local function _hasLineOfSight(fromPos, toPos) + if not (fromPos and toPos) then return false end + local p1 = _vec3(fromPos.x, (fromPos.y or 0) + 2.0, fromPos.z) + local p2 = _vec3(toPos.x, (toPos.y or 0) + 2.0, toPos.z) + local ok, visible = pcall(function() return land.isVisible(p1, p2) end) + return ok and visible == true +end + +local function _jtacTargetScore(unit) + if not unit then return -1 end + if _unitHasAttribute(unit, 'SAM SR') or _unitHasAttribute(unit, 'SAM TR') or _unitHasAttribute(unit, 'SAM CC') or _unitHasAttribute(unit, 'SAM LN') then + return 120 + end + if _unitHasAttribute(unit, 'Air Defence') or _unitHasAttribute(unit, 'AAA') then + return 100 + end + if _unitHasAttribute(unit, 'IR Guided SAM') or _unitHasAttribute(unit, 'SAM') then + return 95 + end + if _unitHasAttribute(unit, 'Artillery') or _unitHasAttribute(unit, 'MLRS') then + return 80 + end + if _unitHasAttribute(unit, 'Armor') or _unitHasAttribute(unit, 'Tanks') then + return 70 + end + if _unitHasAttribute(unit, 'APC') or _unitHasAttribute(unit, 'IFV') then + return 60 + end + if _isDcsInfantry(unit) then + return 20 + end + return 40 +end + local function _msgGroup(group, text, t) if not group then return end MESSAGE:New(text, t or CTLD.Config.MessageDuration):ToGroup(group) @@ -2291,6 +2395,7 @@ function CTLD:New(cfg) o.Config.CountryId = o.CountryId o.MenuRoots = {} o.MenusByGroup = {} + o._jtacRegistry = {} -- If caller disabled builtin catalog, clear it before merging any globals if o.Config.UseBuiltinCatalog == false then @@ -2441,6 +2546,18 @@ function CTLD:New(cfg) end, {}, checkInterval, checkInterval) end + if o.Config.JTAC and o.Config.JTAC.Enabled then + local jtacInterval = 5 + if o.Config.JTAC.AutoLase then + local refresh = tonumber(o.Config.JTAC.AutoLase.RefreshSeconds) or 15 + local idle = tonumber(o.Config.JTAC.AutoLase.IdleRescanSeconds) or 30 + jtacInterval = math.max(2, math.min(refresh, idle, 10)) + end + o.JTACSched = SCHEDULER:New(nil, function() + o:_tickJTACs() + end, {}, jtacInterval, jtacInterval) + end + table.insert(CTLD._instances, o) _msgCoalition(o.Side, string.format('CTLD %s initialized for coalition', CTLD.Version)) return o @@ -4100,6 +4217,7 @@ function CTLD:BuildSpecificAtGroup(group, recipeKey, opts) _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata, self.Config) if not g then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end + self:_maybeRegisterJTAC(recipeKey, def, g) 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 @@ -4134,6 +4252,7 @@ function CTLD:BuildSpecificAtGroup(group, recipeKey, opts) _eventSend(self, group, nil, 'build_started', { build = def.description or recipeKey }) local g = _coalitionAddGroup(def.side or self.Side, def.category or Group.Category.GROUND, gdata, self.Config) if not g then _eventSend(self, group, nil, 'build_failed', { reason = 'DCS group spawn error' }); return end + self:_maybeRegisterJTAC(recipeKey, def, g) consumeCrates(recipeKey, need) _eventSend(self, nil, self.Side, 'build_success_coalition', { build = def.description or recipeKey, player = _playerNameFromGroup(group) }) -- behavior @@ -4161,6 +4280,357 @@ function CTLD:BuildSpecificAtGroup(group, recipeKey, opts) end end +function CTLD:_definitionIsJTAC(def) + if not def then return false end + if def.isJTAC == true then return true end + if type(def.jtac) == 'table' and def.jtac.enabled ~= false then return true end + if type(def.roles) == 'table' then + for _, role in ipairs(def.roles) do + if tostring(role):upper() == 'JTAC' then + return true + end + end + end + return false +end + +function CTLD:_maybeRegisterJTAC(recipeKey, def, dcsGroup) + if not (self.Config.JTAC and self.Config.JTAC.Enabled) then return end + if not self:_definitionIsJTAC(def) then return end + if not dcsGroup then return end + self:_registerJTACGroup(recipeKey, def, dcsGroup) +end + +function CTLD:_reserveJTACCode(side, groupName) + local pool = self.Config.JTAC and self.Config.JTAC.LaserCodes or { '1688' } + if not CTLD._jtacReservedCodes then + CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } + end + CTLD._jtacReservedCodes[side] = CTLD._jtacReservedCodes[side] or {} + for _, code in ipairs(pool) do + code = tostring(code) + if not CTLD._jtacReservedCodes[side][code] then + CTLD._jtacReservedCodes[side][code] = groupName + return code + end + end + local fallback = tostring(pool[1] or '1688') + _logVerbose(string.format('JTAC laser code pool exhausted for side %s, reusing %s', tostring(side), fallback)) + return fallback +end + +function CTLD:_releaseJTACCode(side, code, groupName) + if not code then return end + code = tostring(code) + if CTLD._jtacReservedCodes and CTLD._jtacReservedCodes[side] then + if CTLD._jtacReservedCodes[side][code] == groupName then + CTLD._jtacReservedCodes[side][code] = nil + end + end +end + +function CTLD:_registerJTACGroup(recipeKey, def, dcsGroup) + if not (dcsGroup and dcsGroup.getName) then return end + local groupName = dcsGroup:getName() + if not groupName then return end + + self:_cleanupJTACEntry(groupName) -- ensure stale entry cleared + + local side = dcsGroup:getCoalition() or self.Side + local code = self:_reserveJTACCode(side, groupName) + local platform = 'ground' + if def and def.jtac and def.jtac.platform then + platform = tostring(def.jtac.platform) + elseif def and def.category == Group.Category.AIRPLANE then + platform = 'air' + end + local cfgSmoke = self.Config.JTAC and self.Config.JTAC.Smoke or {} + local smokeColor = (side == coalition.side.BLUE) and cfgSmoke.ColorBlue or cfgSmoke.ColorRed + + local entry = { + groupName = groupName, + recipeKey = recipeKey, + def = def, + side = side, + code = code, + platform = platform, + smokeColor = smokeColor, + nextScan = timer.getTime() + 2, + smokeNext = 0, + lockType = def and def.jtac and def.jtac.lock, + } + + local friendlyName = (def and self:_friendlyNameForKey(recipeKey)) or groupName + entry.displayName = friendlyName + entry.lastState = 'onstation' + + self._jtacRegistry[groupName] = entry + + self:_announceJTAC('jtac_onstation', entry, { + jtac = friendlyName, + code = code, + }) + + _logInfo(string.format('JTAC %s registered (code %s)', groupName, code)) +end + +function CTLD:_announceJTAC(msgKey, entry, payload) + if not entry then return end + local cfg = self.Config.JTAC and self.Config.JTAC.Announcements + if not (cfg and cfg.Enabled ~= false) then return end + local tpl = CTLD.Messages[msgKey] + if not tpl then return end + local data = payload or {} + data.jtac = data.jtac or entry.displayName or entry.groupName + data.code = data.code or entry.code + local text = _fmtTemplate(tpl, data) + if text and text ~= '' then + _msgCoalition(entry.side or self.Side, text, cfg.Duration or self.Config.MessageDuration) + end +end + +function CTLD:_cleanupJTACEntry(groupName) + local entry = self._jtacRegistry and self._jtacRegistry[groupName] + if not entry then return end + self:_cancelJTACSpots(entry) + self:_releaseJTACCode(entry.side or self.Side, entry.code, groupName) + self._jtacRegistry[groupName] = nil +end + +function CTLD:_cancelJTACSpots(entry) + if not entry then return end + if entry.laserSpot then + pcall(function() Spot.destroy(entry.laserSpot) end) + entry.laserSpot = nil + end + if entry.irSpot then + pcall(function() Spot.destroy(entry.irSpot) end) + entry.irSpot = nil + end +end + +function CTLD:_tickJTACs() + if not self._jtacRegistry then return end + if not next(self._jtacRegistry) then return end + local now = timer.getTime() + for groupName, entry in pairs(self._jtacRegistry) do + if not entry.nextScan or now >= entry.nextScan then + local ok, err = pcall(function() + self:_processJTACEntry(groupName, entry, now) + end) + if not ok then + _logError(string.format('JTAC tick error for %s: %s', tostring(groupName), tostring(err))) + entry.nextScan = now + 10 + end + end + end +end + +function CTLD:_processJTACEntry(groupName, entry, now) + local cfg = self.Config.JTAC or {} + local autoCfg = cfg.AutoLase or {} + if autoCfg.Enabled == false then + self:_cancelJTACSpots(entry) + entry.nextScan = now + 30 + return + end + local group = Group.getByName(groupName) + if not group or not group:isExist() then + self:_cleanupJTACEntry(groupName) + return + end + + local units = group:getUnits() or {} + if #units == 0 then + self:_cancelJTACSpots(entry) + entry.nextScan = now + (autoCfg.TransportHoldSeconds or 10) + return + end + + local jtacUnit = units[1] + if not jtacUnit or jtacUnit:getLife() <= 0 or not jtacUnit:isActive() then + self:_cleanupJTACEntry(groupName) + return + end + + entry.jtacUnitName = entry.jtacUnitName or jtacUnit:getName() + entry.displayName = entry.displayName or entry.jtacUnitName or groupName + + local jtacPoint = jtacUnit:getPoint() + local searchRadius = tonumber(autoCfg.SearchRadius) or 8000 + + local current = entry.currentTarget + local targetUnit = nil + local targetStatus = nil + + if current and current.name then + local candidate = Unit.getByName(current.name) + if candidate and candidate:isExist() and candidate:getLife() > 0 then + local tgtPoint = candidate:getPoint() + local dist = _distance3d(tgtPoint, jtacPoint) + if dist <= searchRadius and _hasLineOfSight(jtacPoint, tgtPoint) then + targetUnit = candidate + current.lastSeen = now + current.distance = dist + else + targetStatus = 'lost' + end + else + targetStatus = 'destroyed' + end + if targetStatus then + if targetStatus == 'destroyed' then + if entry.lastState ~= 'destroyed' then + self:_announceJTAC('jtac_target_destroyed', entry, { + jtac = entry.displayName, + target = current.label or current.name, + code = entry.code, + }) + entry.lastState = 'destroyed' + end + else + if entry.lastState ~= 'lost' then + self:_announceJTAC('jtac_target_lost', entry, { + jtac = entry.displayName, + target = current.label or current.name, + }) + entry.lastState = 'lost' + end + end + entry.currentTarget = nil + targetUnit = nil + self:_cancelJTACSpots(entry) + entry.nextScan = now + (targetStatus == 'lost' and (autoCfg.LostRetrySeconds or 10) or 5) + end + end + + if not targetUnit then + local lockPref = entry.lockType or cfg.LockType or 'all' + local selection = self:_findJTACNewTarget(entry, jtacPoint, searchRadius, lockPref) + if selection then + targetUnit = selection.unit + entry.currentTarget = { + name = targetUnit:getName(), + label = targetUnit:getTypeName(), + firstSeen = now, + lastSeen = now, + distance = selection.distance, + } + local grid = self:_GetMGRSString(targetUnit:getPoint()) + local newState = 'target:'..(entry.currentTarget.name or '') + if entry.lastState ~= newState then + self:_announceJTAC('jtac_new_target', entry, { + jtac = entry.displayName, + target = targetUnit:getTypeName(), + code = entry.code, + grid = grid, + }) + entry.lastState = newState + end + end + end + + if targetUnit then + self:_updateJTACSpots(entry, jtacUnit, targetUnit) + entry.nextScan = now + (autoCfg.RefreshSeconds or 15) + else + self:_cancelJTACSpots(entry) + entry.nextScan = now + (autoCfg.IdleRescanSeconds or 30) + if entry.lastState ~= 'idle' then + self:_announceJTAC('jtac_idle', entry, { + jtac = entry.displayName, + }) + entry.lastState = 'idle' + end + end +end + +function CTLD:_findJTACNewTarget(entry, jtacPoint, radius, lockType) + local enemy = _enemySide(entry and entry.side or self.Side) + local best + local lock = (lockType or 'all'):lower() + local ok, groups = pcall(function() + return coalition.getGroups(enemy, Group.Category.GROUND) or {} + end) + if not ok then + groups = {} + end + + for _, grp in ipairs(groups) do + if grp and grp:isExist() then + local units = grp:getUnits() + if units then + for _, unit in ipairs(units) do + if unit and unit:isExist() and unit:isActive() and unit:getLife() > 0 then + local skip = false + if lock == 'troop' and not _isDcsInfantry(unit) then skip = true end + if lock == 'vehicle' and _isDcsInfantry(unit) then skip = true end + if not skip then + local pos = unit:getPoint() + local dist = _distance3d(pos, jtacPoint) + if dist <= radius and _hasLineOfSight(jtacPoint, pos) then + local score = _jtacTargetScore(unit) + if not best or score > best.score or (score == best.score and dist < best.distance) then + best = { unit = unit, score = score, distance = dist } + end + end + end + end + end + end + end + end + + return best +end + +function CTLD:_updateJTACSpots(entry, jtacUnit, targetUnit) + if not (entry and jtacUnit and targetUnit) then return end + local codeNumber = tonumber(entry.code) or 1688 + local targetPoint = targetUnit:getPoint() + targetPoint = _vec3(targetPoint.x, targetPoint.y + 2.0, targetPoint.z) + local origin = { x = 0, y = 2.0, z = 0 } + + if not entry.laserSpot or not entry.irSpot then + local ok, res = pcall(function() + local spots = {} + spots.ir = Spot.createInfraRed(jtacUnit, origin, targetPoint) + spots.laser = Spot.createLaser(jtacUnit, origin, targetPoint, codeNumber) + return spots + end) + if ok and res then + entry.irSpot = entry.irSpot or res.ir + entry.laserSpot = entry.laserSpot or res.laser + else + _logError(string.format('JTAC spot create failed for %s: %s', tostring(entry.groupName), tostring(res))) + end + else + pcall(function() + if entry.laserSpot and entry.laserSpot.setPoint then entry.laserSpot:setPoint(targetPoint) end + if entry.irSpot and entry.irSpot.setPoint then entry.irSpot:setPoint(targetPoint) end + end) + end + + local smokeCfg = self.Config.JTAC and self.Config.JTAC.Smoke or {} + if smokeCfg.Enabled then + local now = timer.getTime() + if not entry.smokeNext or now >= entry.smokeNext then + local color = entry.smokeColor or smokeCfg.ColorBlue or trigger.smokeColor.White + local pos = targetUnit:getPoint() + local offset = tonumber(smokeCfg.OffsetMeters) or 0 + if offset > 0 then + local ang = math.random() * math.pi * 2 + pos.x = pos.x + math.cos(ang) * offset + pos.z = pos.z + math.sin(ang) * offset + end + pcall(function() + trigger.action.smoke({ x = pos.x, y = pos.y, z = pos.z }, color) + end) + entry.smokeNext = now + (smokeCfg.RefreshSeconds or 300) + end + end +end + function CTLD:BuildCoalitionMenus(root) -- Optional: implement coalition-level crate spawns at pickup zones for key,_ in pairs(self.Config.CrateCatalog) do @@ -8048,6 +8518,17 @@ function CTLD:Cleanup() CTLD._msgState = {} CTLD._buildConfirm = {} CTLD._buildCooldown = {} + CTLD._jtacReservedCodes = { [coalition.side.BLUE] = {}, [coalition.side.RED] = {}, [coalition.side.NEUTRAL] = {} } + if self.JTACSched then + pcall(function() self.JTACSched:Stop() end) + self.JTACSched = nil + end + if self._jtacRegistry then + for groupName in pairs(self._jtacRegistry) do + self:_cleanupJTACEntry(groupName) + end + self._jtacRegistry = {} + end _logInfo('Cleanup complete') end diff --git a/Moose_CTLD_Pure/catalogs/Moose_CTLD_Catalog.lua b/Moose_CTLD_Pure/catalogs/Moose_CTLD_Catalog.lua index 91bbe9c..9402612 100644 --- a/Moose_CTLD_Pure/catalogs/Moose_CTLD_Catalog.lua +++ b/Moose_CTLD_Pure/catalogs/Moose_CTLD_Catalog.lua @@ -119,13 +119,13 @@ cat['RED_T72B3'] = { menuCategory='Combat Vehicles', menu='T-72B3', cat['RED_T90M'] = { menuCategory='Combat Vehicles', menu='T-90M', description='T-90M', dcsCargoType='container_cargo', required=3, initialStock=8, side=RED, category=Group.Category.GROUND, build=singleUnit('CHAP_T90M'), unitType='CHAP_T90M', MEDEVAC=true, salvageValue=3, crewSize=3 } -- Support (BLUE) -cat['BLUE_MRAP_JTAC'] = { menuCategory='Support', menu='MRAP - JTAC', description='JTAC MRAP', dcsCargoType='container_cargo', required=1, initialStock=12, side=BLUE, category=Group.Category.GROUND, build=singleUnit('MaxxPro_MRAP'), MEDEVAC=true, salvageValue=1, crewSize=4 } +cat['BLUE_MRAP_JTAC'] = { menuCategory='Support', menu='MRAP - JTAC', description='JTAC MRAP', dcsCargoType='container_cargo', required=1, initialStock=12, side=BLUE, category=Group.Category.GROUND, build=singleUnit('MaxxPro_MRAP'), MEDEVAC=true, salvageValue=1, crewSize=4, roles={'JTAC'}, jtac={ platform='ground' } } cat['BLUE_M818_AMMO'] = { menuCategory='Support', menu='M-818 Ammo Truck', description='M-818 Ammo Truck', dcsCargoType='container_cargo', required=1, initialStock=12, side=BLUE, category=Group.Category.GROUND, build=singleUnit('M 818'), salvageValue=1, crewSize=2 } cat['BLUE_M978_TANKER'] = { menuCategory='Support', menu='M-978 Tanker', description='M-978 Tanker', dcsCargoType='container_cargo', required=1, initialStock=10, side=BLUE, category=Group.Category.GROUND, build=singleUnit('M978 HEMTT Tanker'), salvageValue=1, crewSize=2 } cat['BLUE_EWR_FPS117'] = { menuCategory='Support', menu='EWR Radar FPS-117', description='EWR Radar FPS-117', dcsCargoType='container_cargo', required=1, initialStock=6, side=BLUE, category=Group.Category.GROUND, build=singleUnit('FPS-117'), salvageValue=1, crewSize=3 } -- Support (RED) -cat['RED_TIGR_JTAC'] = { menuCategory='Support', menu='Tigr - JTAC', description='JTAC Tigr', dcsCargoType='container_cargo', required=1, initialStock=12, side=RED, category=Group.Category.GROUND, build=singleUnit('Tigr_233036'), MEDEVAC=true, salvageValue=1, crewSize=4 } +cat['RED_TIGR_JTAC'] = { menuCategory='Support', menu='Tigr - JTAC', description='JTAC Tigr', dcsCargoType='container_cargo', required=1, initialStock=12, side=RED, category=Group.Category.GROUND, build=singleUnit('Tigr_233036'), MEDEVAC=true, salvageValue=1, crewSize=4, roles={'JTAC'}, jtac={ platform='ground' } } cat['RED_URAL4320_AMMO'] = { menuCategory='Support', menu='Ural-4320-31 Ammo Truck', description='Ural-4320-31 Ammo Truck', dcsCargoType='container_cargo', required=1, initialStock=12, side=RED, category=Group.Category.GROUND, build=singleUnit('Ural-4320-31'), salvageValue=1, crewSize=2 } cat['RED_ATZ10_TANKER'] = { menuCategory='Support', menu='ATZ-10 Refueler', description='ATZ-10 Refueler', dcsCargoType='container_cargo', required=1, initialStock=10, side=RED, category=Group.Category.GROUND, build=singleUnit('ATZ-10'), salvageValue=1, crewSize=2 } cat['RED_EWR_1L13'] = { menuCategory='Support', menu='EWR Radar 1L13', description='EWR Radar 1L13', dcsCargoType='container_cargo', required=1, initialStock=6, side=RED, category=Group.Category.GROUND, build=singleUnit('1L13 EWR'), salvageValue=1, crewSize=3 } @@ -233,8 +233,8 @@ cat['RED_BUK_REPAIR'] = { menuCategory='SAM long range', menu='BUK Repai end } -- Drones (JTAC) -cat['BLUE_MQ9'] = { menuCategory='Drones', menu='MQ-9 Reaper - JTAC', description='MQ-9 JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=BLUE, category=Group.Category.AIRPLANE, build=singleAirUnit('MQ-9 Reaper') } -cat['RED_WINGLOONG'] = { menuCategory='Drones', menu='WingLoong-I - JTAC', description='WingLoong-I JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=RED, category=Group.Category.AIRPLANE, build=singleAirUnit('WingLoong-I') } +cat['BLUE_MQ9'] = { menuCategory='Drones', menu='MQ-9 Reaper - JTAC', description='MQ-9 JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=BLUE, category=Group.Category.AIRPLANE, build=singleAirUnit('MQ-9 Reaper'), roles={'JTAC'}, jtac={ platform='air' } } +cat['RED_WINGLOONG'] = { menuCategory='Drones', menu='WingLoong-I - JTAC', description='WingLoong-I JTAC', dcsCargoType='container_cargo', required=1, initialStock=3, side=RED, category=Group.Category.AIRPLANE, build=singleAirUnit('WingLoong-I'), roles={'JTAC'}, jtac={ platform='air' } } -- FOB crates (Support) — three small crates build a FOB site cat['FOB_SMALL'] = { menuCategory='Support', menu='FOB Crate - Small', description='FOB small crate', dcsCargoType='container_cargo', required=1, initialStock=12, side=nil, category=Group.Category.GROUND, build=function(point, headingDeg)