Reorganized the CTLD configuration block in Moose_CTLD.lua so related settings sit together under clear section headers for mission makers.

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.
This commit is contained in:
iTracerFacer 2025-11-10 07:50:32 -06:00
parent df276544c5
commit c2e549c9c1
2 changed files with 571 additions and 90 deletions

View File

@ -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,12 +191,13 @@ 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'
},
-- === 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
@ -198,6 +205,24 @@ CTLD.Config = {
-- 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
@ -236,82 +261,102 @@ CTLD.Config = {
['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
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
PickupZoneSmokeColor = trigger.smokeColor.Green, -- default smoke color when spawning crates at pickup zones
-- Crate Smoke Settings
-- NOTE: Individual smoke effects last ~5 minutes (DCS hardcoded, cannot be changed)
-- These settings control whether/how often NEW smoke is spawned, not how long each smoke lasts
CrateSmoke = {
Enabled = true, -- if true, spawn smoke when crates are created; if false, no smoke at all
AutoRefresh = false, -- if true, automatically spawn new smoke every RefreshInterval seconds (creates continuous smoke)
RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true; 240s = 4min recommended)
MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit; 600s = 10min; set high or disable AutoRefresh for one-time smoke)
OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke (0 = directly on crate)
OffsetRandom = true, -- if true, randomize horizontal offset direction; if false, always offset north
OffsetVertical = 20, -- meters: vertical offset above ground level (helps smoke be more visible; 2-3 recommended)
},
-- === Pickup & Drop Zone Rules ===
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
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
-- 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
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
-- Attack/Defend AI behavior for deployed troops and built vehicles
-- === 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, -- spawn smoke when crates are created; if false, no smoke at all
AutoRefresh = false, -- automatically spawn new smoke every RefreshInterval seconds
RefreshInterval = 240, -- seconds: how often to spawn new smoke (only used if AutoRefresh = true)
MaxRefreshDuration = 600, -- seconds: stop auto-refresh after this long (safety limit)
OffsetMeters = 0, -- meters: horizontal offset from crate so helicopters don't hover in smoke
OffsetRandom = true, -- if true, randomize horizontal offset direction; if false, always offset north
OffsetVertical = 20, -- meters: vertical offset above ground level (helps smoke be more visible)
},
-- === Autonomous Assets ===
-- Air-spawn settings for CTLD-built drones (AIRPLANE catalog entries like MQ-9 / WingLoong)
DroneAirSpawn = {
Enabled = true, -- when true, AIRPLANE catalog items that opt-in can spawn in the air at a set altitude
AltitudeMeters = 5000, -- default spawn altitude ASL (meters)
SpeedMps = 120 -- default initial speed in m/s
},
JTAC = {
Enabled = true,
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
@ -321,6 +366,7 @@ CTLD.Config = {
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
@ -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

View File

@ -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)