DCS_MissionDev/Moose_CTLD_Pure/Moose_CTLD_FAC.lua
2025-11-05 21:23:52 -06:00

1333 lines
57 KiB
Lua

-- Moose_CTLD_FAC.lua
--[[
Full-featured FAC/RECCE module (FAC2 parity) for pure-MOOSE CTLD, without MIST
==========================================================================
Dependencies: MOOSE (Moose.lua) and DCS core. No MIST required.
Capabilities
- AFAC/RECCE auto-detect (by group name or unit type) on Birth; per-group F10 menu
- Auto-lase (laser + IR) using DCS Spot API; configurable marker type/color; per-FAC laser code
- Manual target workflow: scan nearby, list top 10 (prioritize AA/SAM), select one, multi-strike helper
- RECCE sweeps: LoS-based scan; adds map marks with DMS/MGRS/alt/heading/speed; stores target list
- Fires & strikes: Artillery, Naval guns, Bombers/Fighters; HE/illum/mortar, guided multi-task combos
- Carpet/TALD: via menu or map marks (CBRQT/TDRQT/AttackAz <heading>)
- Event handling: Map marks (tasking), Shots (re-task), periodic schedulers (menus, status, AI spotter)
Quick start
1) Load order: Moose.lua -> Moose_CTLD.lua -> Moose_CTLD_FAC.lua
2) In mission init: local fac = _MOOSE_CTLD_FAC:New(ctld, { CoalitionSide = coalition.side.BLUE })
3) Put players in groups named with AFAC/RECON/RECCE (or use configured aircraft types)
4) Use F10 FAC/RECCE: Auto Laze ON, Scan, Select Target, Artillery, etc.
Design notes
- This module aims to match FAC2 behaviors using DCS+MOOSE equivalents; some heuristics (e.g., naval ranges)
are conservative approximations to avoid silent failures.
]]
if not _G.BASE then
env.info('[Moose_CTLD_FAC] Moose (BASE) not detected. Ensure Moose.lua is loaded before this script.')
end
local FAC = {}
FAC.__index = FAC
FAC.Version = '0.1.0-alpha'
-- Safe deep copy: prefer MOOSE UTILS.DeepCopy when available; fallback to Lua implementation
local function _deepcopy_fallback(obj, seen)
if type(obj) ~= 'table' then return obj end
seen = seen or {}
if seen[obj] then return seen[obj] end
local res = {}
seen[obj] = res
for k, v in pairs(obj) do
res[_deepcopy_fallback(k, seen)] = _deepcopy_fallback(v, seen)
end
local mt = getmetatable(obj)
if mt then setmetatable(res, mt) end
return res
end
local function DeepCopy(obj)
if _G.UTILS and type(UTILS.DeepCopy) == 'function' then
return UTILS.DeepCopy(obj)
end
return _deepcopy_fallback(obj)
end
-- Deep-merge src into dst (recursively). Arrays/lists in src replace dst.
local function DeepMerge(dst, src)
if type(dst) ~= 'table' or type(src) ~= 'table' then return src end
for k, v in pairs(src) do
if type(v) == 'table' then
local isArray = (rawget(v, 1) ~= nil)
if isArray then
dst[k] = DeepCopy(v)
else
dst[k] = DeepMerge(dst[k] or {}, v)
end
else
dst[k] = v
end
end
return dst
end
-- #region Config
-- Configuration for FAC behavior and UI. Adjust defaults here or pass overrides to :New().
FAC.Config = {
CoalitionSide = coalition.side.BLUE,
UseGroupMenus = true,
Debug = false,
-- Visuals / marking
FAC_maxDistance = 18520, -- FAC LoS search distance (m)
FAC_smokeOn_RED = true,
FAC_smokeOn_BLUE = true,
FAC_smokeColour_RED = trigger.smokeColor.Green,
FAC_smokeColour_BLUE = trigger.smokeColor.Orange,
MarkerDefault = 'FLARES', -- 'FLARES' | 'SMOKE'
FAC_location = true, -- include coords in messages
FAC_lock = 'all', -- 'vehicle' | 'troop' | 'all'
FAC_laser_codes = { '1688','1677','1666','1113','1115','1111' },
fireMissionRounds = 24, -- default shells per call
illumHeight = 500, -- illumination height
facOffsetDist = 5000, -- offset aimpoint for mortars
-- Platform type hints (names or types)
facACTypes = { 'SA342L','UH-1H','Mi-8MTV2','SA342M','SA342Minigun','Mi-24P','AH-64D_BLK_II','Ka-50','Ka-50_3' },
artyDirectorTypes = { 'Soldier M249','Paratrooper AKS-74','Soldier M4' },
-- RECCE scan
RecceScanRadius = 40000,
MinReportSeparation = 400,
-- Arty tasking
Arty = {
Enabled = true,
},
}
-- #endregion Config
-- #region State
-- Internal state tracking for FACs, targets, menus, and tasking
FAC._ctld = nil
FAC._menus = {} -- [groupName] = { root = MENU_GROUP, ... }
FAC._facUnits = {} -- [unitName] = { name, side }
FAC._facOnStation = {} -- [unitName] = true|nil
FAC._laserCodes = {} -- [unitName] = '1688'
FAC._markerType = {} -- [unitName] = 'FLARES'|'SMOKE'
FAC._markerColor = {} -- [unitName] = smokeColor (0..4)
FAC._currentTargets = {} -- [unitName] = { name, unitType, unitId }
FAC._laserSpots = {} -- [unitName] = { ir=Spot, laser=Spot }
FAC._smokeMarks = {} -- [targetName] = nextTime
FAC._manualLists = {} -- [unitName] = { Unit[] }
FAC._facPilotNames = {} -- dynamic add on Birth if name contains AFAC/RECON/RECCE or type in facACTypes
FAC._reccePilotNames = {}
FAC._artDirectNames = {}
-- Laser code reservation per coalition side: [side] = { [code] = unitName }
FAC._reservedCodes = {}
-- Coalition-level admin/help menu handle per side
FAC._coalitionMenus = {}
FAC._ArtyTasked = {} -- [groupName] = { tasked=int, timeTasked=time, tgt=Unit|nil, requestor=string|nil }
FAC._RECCETasked = {} -- [unitName] = 1 when busy
-- Map mark debouncing
FAC._lastMarks = {} -- [zoneName] = { x,z }
-- #endregion State
-- #region Utilities (no MIST)
-- Helpers for logging, vectors, coordinate formatting, headings, classification, etc.
local function _dbg(self, msg)
if self.Config.Debug then env.info('[FAC] '..msg) end
end
local function _in(list, value)
if not list then return false end
for _,v in ipairs(list) do if v == value then return true end end
return false
end
local function _vec3(p)
return { x = p.x, y = p.y or land.getHeight({ x = p.x, y = p.z or p.y or 0 }), z = p.z or p.y }
end
local function _llToDMS(lat, lon)
-- Convert lat/lon in degrees to DMS string (e.g., 33°30'12.34"N 036°12'34.56"E)
local function dms(v, isLat)
local hemi = isLat and (v >= 0 and 'N' or 'S') or (v >= 0 and 'E' or 'W')
v = math.abs(v)
local d = math.floor(v)
local mFloat = (v - d) * 60
local m = math.floor(mFloat)
local s = (mFloat - m) * 60
return string.format('%d°%02d\'%05.2f"%s', d, m, s, hemi)
end
return dms(lat, true)..' '..dms(lon, false)
end
local function _mgrsToString(m)
-- Format DCS coord.LLtoMGRS table to "XXYY 00000 00000"; fallback to key=value if shape differs
if not m then return '' end
-- DCS coord.LLtoMGRS returns table like: { UTMZone=XX, MGRSDigraph=YY, Easting=nnnnn, Northing=nnnnnn }
if m.UTMZone and m.MGRSDigraph and m.Easting and m.Northing then
return string.format('%s%s %05d %05d', tostring(m.UTMZone), tostring(m.MGRSDigraph), math.floor(m.Easting+0.5), math.floor(m.Northing+0.5))
end
-- fallback stringify
local t = {}
for k,v in pairs(m) do table.insert(t, tostring(k)..'='..tostring(v)) end
return table.concat(t, ',')
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))
if ang < 0 then ang = ang + 360 end
return math.floor(ang + 0.5)
end
local function _distance(a, b)
local dx = a.x - b.x
local dz = a.z - b.z
return math.sqrt(dx*dx + dz*dz)
end
local function _getHeading(unit)
-- Approximate true heading using unit orientation + true north correction
local pos = unit:getPosition()
if pos then
local heading = math.atan2(pos.x.z, pos.x.x)
-- add true-north correction
local p = pos.p
local lat, lon = coord.LOtoLL(p)
local northPos = coord.LLtoLO(lat + 1, lon)
heading = heading + math.atan2(northPos.z - p.z, northPos.x - p.x)
if heading < 0 then heading = heading + 2*math.pi end
return heading
end
return 0
end
local function _formatUnitGeo(u)
-- Extracts geo/status for a unit: DMS/MGRS, altitude (m/ft), heading (deg), speed (mph)
local p = u:getPosition().p
local lat, lon = coord.LOtoLL(p)
local dms = _llToDMS(lat, lon)
local mgrs = _mgrsToString(coord.LLtoMGRS(lat, lon))
local altM = math.floor(p.y)
local altF = math.floor(p.y * 3.28084)
local vel = u:getVelocity() or {x=0,y=0,z=0}
local spd = math.sqrt((vel.x or 0)^2 + (vel.z or 0)^2)
local mph = math.floor(spd * 2) -- approx
local hdg = math.floor(_getHeading(u) * 180/math.pi)
return dms, mgrs, altM, altF, hdg, mph
end
local function _isInfantry(u)
-- Heuristic: treat named manpads/mortars as infantry
local tn = string.lower(u:getTypeName() or '')
return tn:find('infantry') or tn:find('paratrooper') or tn:find('stinger') or tn:find('manpad') or tn:find('mortar')
end
local function _isVehicle(u)
return not _isInfantry(u)
end
local function _isArtilleryUnit(u)
-- Detect tube/MLRS artillery; include mortar/howitzer/SPG by type name to cover units lacking attributes
if u:hasAttribute('Artillery') or u:hasAttribute('MLRS') then return true end
local tn = string.lower(u:getTypeName() or '')
if tn:find('mortar') or tn:find('2b11') or tn:find('m252') then return true end
if tn:find('howitzer') or tn:find('m109') or tn:find('paladin') or tn:find('2s19') or tn:find('msta') or tn:find('2s3') or tn:find('akatsiya') then return true end
if tn:find('mlrs') or tn:find('m270') or tn:find('bm%-21') or tn:find('grad') then return true end
return false
end
local function _isNavalUnit(u)
-- Use DCS attributes to detect surface ships with guns capability
return u:hasAttribute('Naval') or u:hasAttribute('Cruisers') or u:hasAttribute('Frigates') or u:hasAttribute('Corvettes') or u:hasAttribute('Landing Ships')
end
local function _isBomberOrFighter(u)
-- Detect fixed-wing strike-capable aircraft (for carpet/guided tasks)
return u:hasAttribute('Strategic bombers') or u:hasAttribute('Bombers') or u:hasAttribute('Multirole fighters')
end
local function _artyMaxRangeForUnit(u)
-- Heuristic max range (meters) by unit type name; conservative to avoid "never fires" when out of range
local tn = string.lower(u:getTypeName() or '')
if tn:find('mortar') or tn:find('2b11') or tn:find('m252') then return 6000 end
if tn:find('mlrs') or tn:find('m270') or tn:find('bm%-21') or tn:find('grad') then return 30000 end
if tn:find('howitzer') or tn:find('m109') or tn:find('paladin') or tn:find('2s19') or tn:find('msta') or tn:find('2s3') or tn:find('akatsiya') then return 20000 end
-- generic tube artillery fallback
return 12000
end
local function _coalitionOpposite(side)
return (side == coalition.side.BLUE) and coalition.side.RED or coalition.side.BLUE
end
-- #endregion Utilities (no MIST)
-- #region Construction
-- Create a new FAC module instance. Optionally pass your CTLD instance and a config override table.
function FAC:New(ctld, cfg)
local o = setmetatable({}, self)
o._ctld = ctld
o.Config = DeepCopy(FAC.Config)
if cfg then o.Config = DeepMerge(o.Config, cfg) end
o.Side = o.Config.CoalitionSide
o._zones = {}
o:_wireBirth()
o:_wireMarks()
o:_wireShots()
-- Schedulers for menus/status/lase loop/AI spotters
o._schedMenus = SCHEDULER:New(nil, function() o:_ensureMenus() end, {}, 5, 10)
o._schedStatus = SCHEDULER:New(nil, function() o:_checkFacStatus() end, {}, 5, 1.0)
o._schedAI = SCHEDULER:New(nil, function() o:_artyAICall() end, {}, 10, 30)
-- Only create coalition-level Admin/Help when not using per-group menus
if not o.Config.UseGroupMenus then
o:_ensureCoalitionMenu()
end
return o
end
-- #endregion Construction
-- #region Event wiring
-- Wire Birth (to detect AFAC/RECCE/Artillery Director), Map Mark handlers (tasking), and Shot events (re-tasking)
function FAC:_wireBirth()
local h = EVENTHANDLER:New()
h:HandleEvent(EVENTS.Birth)
local selfref = self
function h:OnEventBirth(e)
local unit = e.IniUnit
if not unit or not unit:IsAlive() then return end
if unit:GetCoalition() ~= selfref.Side then return end
-- classify as AFAC / RECCE / Arty Director
local name = unit:GetName()
local tname = unit:GetTypeName()
local g = unit:GetGroup()
if not g then return end
local gname = g:GetName() or name
local isAFAC = (gname:find('AFAC') or gname:find('RECON')) or _in(selfref.Config.facACTypes, tname)
local isRECCE = (gname:find('RECCE') or gname:find('RECON')) or _in(selfref.Config.facACTypes, tname)
local isAD = _in(selfref.Config.artyDirectorTypes, tname)
if isAFAC then selfref._facPilotNames[name] = true end
if isRECCE then selfref._reccePilotNames[name] = true end
if isAD then selfref._artDirectNames[name] = true end
end
self._hBirth = h
end
function FAC:_wireMarks()
-- Map mark handlers for Carpet Bomb/TALD and RECCE area tasks
local selfref = self
self._markEH = {}
function self._markEH:onEvent(e)
if not e or not e.id then return end
if e.id == world.event.S_EVENT_MARK_ADDED then
if type(e.text) == 'string' then
if e.text:find('CBRQT') or e.text:find('TDRQT') or e.text:find('AttackAz') then
local az = tonumber((e.text or ''):match('(%d+)%s*$'))
local mode = e.text:find('TDRQT') and 'TALD' or 'CARPET'
selfref:_executeCarpetOrTALD(e.pos, e.coalition, mode, az)
trigger.action.removeMark(e.idx)
elseif e.text:find('RECCE') then
selfref:_executeRecceMark(e.pos, e.coalition)
trigger.action.removeMark(e.idx)
end
end
end
end
world.addEventHandler(self._markEH)
end
function FAC:_wireShots()
local selfref = self
self._shotEH = {}
function self._shotEH:onEvent(e)
if e.id == world.event.S_EVENT_SHOT and e.initiator then
local g = Unit.getGroup(e.initiator)
if not g then return end
local gname = g:getName()
local T = selfref._ArtyTasked[gname]
if T then
T.tasked = math.max(0, (T.tasked or 0) - 1)
if T.tasked == 0 then
local d = g:getUnit(1):getDesc()
trigger.action.outTextForCoalition(g:getCoalition(), (d and d.displayName or gname)..' Task Group available for re-tasking', 10)
end
end
end
end
world.addEventHandler(self._shotEH)
end
-- #endregion Event wiring
-- #region Zone-based RECCE (optional)
-- Add a named or coordinate-based zone for periodic DETECTION_AREAS scans
function FAC:AddRecceZone(def)
local z
if def.name then z = ZONE:FindByName(def.name) end
if not z and def.coord then
local r = def.radius or 5000
local v2 = (VECTOR2 and VECTOR2.New) and VECTOR2:New(def.coord.x, def.coord.z) or { x = def.coord.x, y = def.coord.z }
z = ZONE_RADIUS:New(def.name or ('FAC_ZONE_'..math.random(10000,99999)), v2, r)
end
if not z then return nil end
local enemySide = _coalitionOpposite(self.Side)
local setEnemies = SET_GROUP:New():FilterCoalitions(enemySide):FilterCategoryGround():FilterStart()
local det = DETECTION_AREAS:New(setEnemies, z:GetRadius())
det:BoundZone(z)
local Z = { Zone = z, Name = z:GetName(), Detector = det, LastScan = 0 }
table.insert(self._zones, Z)
return Z
end
function FAC:RunZones(interval)
-- Start/Restart periodic scans of configured recce zones
if self._zoneSched then self._zoneSched:Stop() end
self._zoneSched = SCHEDULER:New(nil, function()
for _,Z in ipairs(self._zones) do self:_scanZone(Z) end
end, {}, 5, interval or 20)
end
-- Backwards-compatible Run() entry point used by init scripts
function FAC:Run()
-- Schedulers for menus/status are started in New(); here we can kick off zone scans if any zones exist.
if #self._zones > 0 then self:RunZones() end
return self
end
function FAC:_scanZone(Z)
-- Perform one detection update and mark contacts, with spatial de-duplication
Z.Detector:DetectionUpdate()
local reps = Z.Detector:GetDetectedItems() or {}
for _,rep in ipairs(reps) do
local pos2 = rep.point
if pos2 then
local point = { x = pos2.x, z = pos2.y }
local last = self._lastMarks[Z.Name]
if not last or _distance(point, last) >= (self.Config.MinReportSeparation or 400) then
self._lastMarks[Z.Name] = { x = point.x, z = point.z }
self:_markPoint(nil, point, rep.type or 'Contact')
end
end
end
end
-- #endregion Zone-based RECCE (optional)
-- #region Menus
-- Ensure per-group menus exist for active coalition player groups
function FAC:_ensureMenus()
if not self.Config.UseGroupMenus then return end
local players = coalition.getPlayers(self.Side) or {}
for _,u in ipairs(players) do
local dg = u:getGroup()
if dg then
local gname = dg:getName()
if not self._menus[gname] then
local mg = GROUP:FindByName(gname)
if mg then
self._menus[gname] = self:_buildGroupMenus(mg)
end
end
end
end
end
function FAC:_ensureCoalitionMenu()
if self.Config.UseGroupMenus then return end
-- Create a coalition-level Admin/Help menu, nested under a FAC parent (not at F10 root)
if self._coalitionMenus[self.Side] then return end
self._coalitionRoot = self._coalitionRoot or {}
-- Create or reuse the coalition-level parent menu for FAC
self._coalitionRoot[self.Side] = self._coalitionRoot[self.Side] or MENU_COALITION:New(self.Side, 'FAC/RECCE Admin')
local parent = self._coalitionRoot[self.Side]
local root = MENU_COALITION:New(self.Side, 'Admin/Help', parent)
MENU_COALITION_COMMAND:New(self.Side, 'Show FAC Codes In Use', root, function()
self:_showCodesCoalition()
end)
MENU_COALITION_COMMAND:New(self.Side, 'Enable FAC Debug Logging', root, function()
self.Config.Debug = true
env.info(string.format('[FAC][%s] Debug ENABLED via Admin menu', tostring(self.Side)))
trigger.action.outTextForCoalition(self.Side, 'FAC Debug logging ENABLED', 8)
end)
MENU_COALITION_COMMAND:New(self.Side, 'Disable FAC Debug Logging', root, function()
self.Config.Debug = false
env.info(string.format('[FAC][%s] Debug DISABLED via Admin menu', tostring(self.Side)))
trigger.action.outTextForCoalition(self.Side, 'FAC Debug logging DISABLED', 8)
end)
self._coalitionMenus[self.Side] = root
end
function FAC:_buildGroupMenus(group)
-- Build the entire FAC/RECCE menu tree for a MOOSE GROUP
local root = MENU_GROUP:New(group, 'FAC/RECCE')
-- Safe menu command helper: wraps callbacks to avoid silent errors and report to group
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('[FAC] Menu error: '..tostring(err))
MESSAGE:New('FAC menu error: '..tostring(err), 8):ToGroup(group)
end
end)
end
-- Status & On-Station
CMD('FAC: Status', root, function() self:_showFacStatus(group) end)
local tgtRoot = MENU_GROUP:New(group, 'Targeting Mode', root)
CMD('Auto Laze ON', tgtRoot, function() self:_setOnStation(group, true) end)
CMD('Auto Laze OFF', tgtRoot, function() self:_setOnStation(group, nil) end)
CMD('Scan for Close Targets', tgtRoot, function() self:_scanManualList(group) end)
local selRoot = MENU_GROUP:New(group, 'Select Found Target', tgtRoot)
for i=1,10 do
CMD('Target '..i, selRoot, function() self:_setManualTarget(group, i) end)
end
CMD('Call arty on all manual targets', tgtRoot, function() self:_multiStrike(group) end)
-- Laser codes
local lzr = MENU_GROUP:New(group, 'Laser Code', root)
for _,code in ipairs(self.Config.FAC_laser_codes) do
CMD(code, lzr, function() self:_setLaserCode(group, code) end)
end
local cust = MENU_GROUP:New(group, 'Custom Code', lzr)
local function addDigitMenu(d, max)
local m = MENU_GROUP:New(group, 'Digit '..d, cust)
for n=1,max do
CMD(tostring(n), m, function() self:_setLaserDigit(group, d, n) end)
end
end
addDigitMenu(1,1); addDigitMenu(2,6); addDigitMenu(3,8); addDigitMenu(4,8)
-- Marker
local mk = MENU_GROUP:New(group, 'Marker', root)
local sm = MENU_GROUP:New(group, 'Smoke', mk)
local fl = MENU_GROUP:New(group, 'Flares', mk)
local function setM(typeName, color)
return function() self:_setMarker(group, typeName, color) end
end
CMD('GREEN', sm, setM('SMOKE', trigger.smokeColor.Green))
CMD('RED', sm, setM('SMOKE', trigger.smokeColor.Red))
CMD('WHITE', sm, setM('SMOKE', trigger.smokeColor.White))
CMD('ORANGE', sm, setM('SMOKE', trigger.smokeColor.Orange))
CMD('BLUE', sm, setM('SMOKE', trigger.smokeColor.Blue))
CMD('GREEN', fl, setM('FLARES', trigger.smokeColor.Green))
CMD('WHITE', fl, setM('FLARES', trigger.smokeColor.White))
CMD('ORANGE', fl, setM('FLARES', trigger.smokeColor.Orange))
CMD('Map Marker current target', mk, function() self:_setMapMarker(group) end)
-- Artillery
local arty = MENU_GROUP:New(group, 'Artillery', root)
CMD('Check available arty', arty, function() self:_checkArty(group) end)
CMD('Call Fire Mission (HE)', arty, function() self:_callFireMission(group, self.Config.fireMissionRounds, 0) end)
CMD('Call Illumination', arty, function() self:_callFireMission(group, self.Config.fireMissionRounds, 1) end)
CMD('Call Mortar Only (anti-infantry)', arty, function() self:_callFireMission(group, self.Config.fireMissionRounds, 2) end)
CMD('Call Heavy Only (no smart)', arty, function() self:_callFireMission(group, 10, 3) end)
local air = MENU_GROUP:New(group, 'Air/Naval', arty)
CMD('Single Target (GPS/Guided)', air, function() self:_callFireMission(group, 1, 4) end)
CMD('Multi Target (Guided only)', air, function() self:_callFireMissionMulti(group, 1, 4) end)
CMD('Carpet Bomb (attack heading = aircraft heading)', air, function() self:_callCarpetOnCurrent(group) end)
-- RECCE
CMD('RECCE: Sweep & Mark', root, function() self:_recceDetect(group) end)
-- Admin/Help (nested inside FAC/RECCE group menu when using group menus)
local admin = MENU_GROUP:New(group, 'Admin/Help', root)
CMD('Show FAC Codes In Use', admin, function() self:_showCodesCoalition() end)
CMD('Enable FAC Debug Logging', admin, function()
self.Config.Debug = true
env.info(string.format('[FAC][%s] Debug ENABLED via Admin menu', tostring(self.Side)))
MESSAGE:New('FAC Debug logging ENABLED', 8):ToGroup(group)
end)
CMD('Disable FAC Debug Logging', admin, function()
self.Config.Debug = false
env.info(string.format('[FAC][%s] Debug DISABLED via Admin menu', tostring(self.Side)))
MESSAGE:New('FAC Debug logging DISABLED', 8):ToGroup(group)
end)
-- Debug controls (mission-maker convenience; per-instance toggle)
local dbg = MENU_GROUP:New(group, 'Debug', root)
CMD('Enable Debug Logging', dbg, function()
self.Config.Debug = true
local u = group:GetUnit(1); local who = (u and u:GetName()) or 'Unknown'
env.info(string.format('[FAC] Debug ENABLED by %s', who))
MESSAGE:New('FAC Debug logging ENABLED', 8):ToGroup(group)
end)
CMD('Disable Debug Logging', dbg, function()
self.Config.Debug = false
local u = group:GetUnit(1); local who = (u and u:GetName()) or 'Unknown'
env.info(string.format('[FAC] Debug DISABLED by %s', who))
MESSAGE:New('FAC Debug logging DISABLED', 8):ToGroup(group)
end)
MESSAGE:New('FAC/RECCE menu ready (F10)', 10):ToGroup(group)
return { root = root }
end
-- #endregion Menus
-- #region Status & On-station
function FAC:_facName(unitName)
local u = Unit.getByName(unitName)
if u and u:getPlayerName() then return u:getPlayerName() end
return unitName
end
function FAC:_showFacStatus(group)
local unit = group:GetUnit(1)
if not unit or not unit:IsAlive() then return end
local side = unit:GetCoalition()
local colorToStr = { [trigger.smokeColor.Green]='GREEN',[trigger.smokeColor.Red]='RED',[trigger.smokeColor.White]='WHITE',[trigger.smokeColor.Orange]='ORANGE',[trigger.smokeColor.Blue]='BLUE' }
local msg = 'FAC STATUS:\n\n'
for uname,_ in pairs(self._facUnits) do
local u = Unit.getByName(uname)
if u and u:getLife()>0 and u:isActive() and u:getCoalition()==side and self._facOnStation[uname] then
local tgt = self._currentTargets[uname]
local lcd = self._laserCodes[uname] or 'UNKNOWN'
local marker = self._markerType[uname] or self.Config.MarkerDefault
local mcol = self._markerColor[uname]
local mcolStr = mcol and (colorToStr[mcol] or tostring(mcol)) or 'WHITE'
if tgt then
local eu = Unit.getByName(tgt.name)
if eu and eu:isActive() and eu:getLife()>0 then
msg = msg .. string.format('%s targeting %s CODE %s %s\nMarked %s %s\n', self:_facName(uname), eu:getTypeName(), lcd, self:_posString(eu), mcolStr, marker)
else
msg = msg .. string.format('%s on-station CODE %s\n', self:_facName(uname), lcd)
end
else
msg = msg .. string.format('%s on-station CODE %s\n', self:_facName(uname), lcd)
end
end
end
if msg == 'FAC STATUS:\n\n' then
msg = 'No Active FACs. Join AFAC/RECON to play as flying JTAC and Artillery Spotter.'
end
trigger.action.outTextForCoalition(side, msg, 20)
end
function FAC:_posString(u)
-- Render a compact position string for messages
if not self.Config.FAC_location then return '' end
local p = u:getPosition().p
local lat, lon = coord.LOtoLL(p)
local dms = _llToDMS(lat, lon)
local mgrs = _mgrsToString(coord.LLtoMGRS(lat, lon))
local altM = math.floor(p.y)
local altF = math.floor(p.y*3.28084)
return string.format('@ DMS %s MGRS %s Alt %dm/%dft', dms, mgrs, altM, altF)
end
function FAC:_setOnStation(group, on)
local u = group:GetUnit(1)
if not u or not u:IsAlive() then return end
local uname = u:GetName()
_dbg(self, string.format('Action:SetOnStation unit=%s on=%s', uname, tostring(on and true or false)))
-- init defaults
if not self._laserCodes[uname] then
-- Assign a free code on first-time activation
local code = self:_assignFreeCode(u:GetCoalition(), uname)
self._laserCodes[uname] = code or (self.Config.FAC_laser_codes and self.Config.FAC_laser_codes[1]) or '1688'
end
if not self._markerType[uname] then self._markerType[uname] = self.Config.MarkerDefault end
if not self._facUnits[uname] then self._facUnits[uname] = { name = uname, side = u:GetCoalition() } end
if not self._facOnStation[uname] and on then
trigger.action.outTextForCoalition(u:GetCoalition(), string.format('[FAC "%s" on-station using CODE %s]', self:_facName(uname), self._laserCodes[uname]), 10)
elseif self._facOnStation[uname] and not on then
trigger.action.outTextForCoalition(u:GetCoalition(), string.format('[FAC "%s" off-station]', self:_facName(uname)), 10)
self:_cancelLase(uname)
self._currentTargets[uname] = nil
self._facUnits[uname] = nil
self:_releaseCode(u:GetCoalition(), uname)
end
self._facOnStation[uname] = on and true or nil
-- start autolase one-shot; the status scheduler keeps it alive every 1s
if on then self:_autolase(uname) end
end
function FAC:_setLaserCode(group, code)
-- Set the laser code for this FAC; updates status if on-station
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
_dbg(self, string.format('Action:SetLaserCode unit=%s code=%s', uname, tostring(code)))
-- Enforce simple reservation: reassign if taken
local assigned = self:_reserveCode(u:GetCoalition(), uname, tostring(code))
self._laserCodes[uname] = assigned
if self._facOnStation[uname] then
trigger.action.outTextForCoalition(u:GetCoalition(), string.format('[FAC "%s" on-station using CODE %s]', self:_facName(uname), self._laserCodes[uname]), 10)
end
end
function FAC:_setLaserDigit(group, digit, val)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
_dbg(self, string.format('Action:SetLaserDigit unit=%s digit=%d val=%s', uname, digit, tostring(val)))
local cur = self._laserCodes[uname] or '1688'
local s = tostring(cur)
if #s ~= 4 then s = '1688' end
local pre = s:sub(1, digit-1)
local post = s:sub(digit+1)
s = pre .. tostring(val) .. post
self:_setLaserCode(group, s)
end
function FAC:_setMarker(group, typ, color)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
_dbg(self, string.format('Action:SetMarker unit=%s type=%s color=%s', uname, tostring(typ), tostring(color)))
self._markerType[uname] = typ
self._markerColor[uname] = color
local colorStr = ({[trigger.smokeColor.Green]='GREEN',[trigger.smokeColor.Red]='RED',[trigger.smokeColor.White]='WHITE',[trigger.smokeColor.Orange]='ORANGE',[trigger.smokeColor.Blue]='BLUE'})[color] or 'WHITE'
if self._facOnStation[uname] then
trigger.action.outTextForCoalition(u:GetCoalition(), string.format('[FAC "%s" on-station marking with %s %s]', self:_facName(uname), colorStr, typ), 10)
else
MESSAGE:New('Marker set to '..colorStr..' '..typ, 10):ToGroup(group)
end
end
function FAC:_setMapMarker(group)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
local tgt = self._currentTargets[uname]
if not tgt then MESSAGE:New('No Target to Mark', 10):ToGroup(group); return end
local t = Unit.getByName(tgt.name)
if not t or not t:isActive() then return end
_dbg(self, string.format('Action:MapMarker unit=%s target=%s', uname, tgt.name))
local dms, mgrs, altM, altF, hdg, mph = _formatUnitGeo(t)
local text = string.format('%s - DMS %s Alt %dm/%dft\nHeading %d Speed %d MPH\nSpotted by %s', t:getTypeName(), dms, altM, altF, hdg, mph, self:_facName(uname))
local id = math.floor(timer.getTime()*1000 + 0.5)
trigger.action.markToCoalition(id, text, t:getPoint(), u:GetCoalition(), false)
timer.scheduleFunction(function(idp) trigger.action.removeMark(idp) end, id, timer.getTime() + 300)
end
-- #endregion Status & On-station
-- #region Auto-lase loop & target selection
function FAC:_checkFacStatus()
-- Autostart for AI FACs and run autolase cadence
for uname,_ in pairs(self._facPilotNames) do
local u = Unit.getByName(uname)
if u and u:isActive() and u:getLife()>0 then
if not self._facUnits[uname] and (u:getPlayerName() == nil) then
self._facOnStation[uname] = true
end
if (not self._facUnits[uname]) and self._facOnStation[uname] then
self:_autolase(uname)
end
end
end
end
function FAC:_autolase(uname)
local u = Unit.getByName(uname)
if not u then
if self._facUnits[uname] then
trigger.action.outTextForCoalition(self._facUnits[uname].side, string.format('[FAC "%s" MIA]', self:_facName(uname)), 10)
end
self:_cleanupFac(uname)
return
end
if not self._facOnStation[uname] then self:_cancelLase(uname); self._currentTargets[uname]=nil; return end
if not self._laserCodes[uname] then self._laserCodes[uname] = self.Config.FAC_laser_codes[1] end
if not self._facUnits[uname] then self._facUnits[uname] = { name = u:getName(), side = u:getCoalition() } end
if not self._markerType[uname] then self._markerType[uname] = self.Config.MarkerDefault end
if not u:isActive() then
timer.scheduleFunction(function(args) self:_autolase(args[1]) end, {uname}, timer.getTime()+30)
return
end
local enemy = self:_currentOrFindEnemy(u, uname)
if enemy then
_dbg(self, string.format('AutoLase: unit=%s target=%s type=%s', uname, enemy:getName(), enemy:getTypeName()))
self:_laseUnit(enemy, u, uname, self._laserCodes[uname])
-- variable next tick based on target speed
local v = enemy:getVelocity() or {x=0,z=0}
local spd = math.sqrt((v.x or 0)^2 + (v.z or 0)^2)
local next = (spd < 1) and 1 or 1/spd
timer.scheduleFunction(function(args) self:_autolase(args[1]) end, {uname}, timer.getTime()+next)
-- markers recurring
local nm = self._smokeMarks[enemy:getName()]
if nm and nm < timer.getTime() then self:_createMarker(enemy, uname) end
else
_dbg(self, string.format('AutoLase: unit=%s no-visible-target -> cancel', uname))
self:_cancelLase(uname)
timer.scheduleFunction(function(args) self:_autolase(args[1]) end, {uname}, timer.getTime()+5)
end
end
function FAC:_currentOrFindEnemy(facUnit, uname)
local cur = self._currentTargets[uname]
if cur then
local eu = Unit.getByName(cur.name)
if eu and eu:isActive() and eu:getLife()>0 then
local d = _distance(eu:getPoint(), facUnit:getPoint())
if d < (self.Config.FAC_maxDistance or 18520) then
local epos = eu:getPoint()
if land.isVisible({x=epos.x,y=epos.y+2,z=epos.z}, {x=facUnit:getPoint().x,y=facUnit:getPoint().y+2,z=facUnit:getPoint().z}) then
return eu
end
end
end
end
-- find nearest visible
_dbg(self, string.format('FindNearest: unit=%s mode=%s', uname, tostring(self.Config.FAC_lock)))
return self:_findNearestEnemy(facUnit, self.Config.FAC_lock)
end
function FAC:_findNearestEnemy(facUnit, targetType)
local facSide = facUnit:getCoalition()
local enemySide = _coalitionOpposite(facSide)
local nearest, best = nil, self.Config.FAC_maxDistance or 18520
local origin = facUnit:getPoint()
_dbg(self, string.format('Search: origin=(%.0f,%.0f) radius=%d targetType=%s', origin.x, origin.z, self.Config.FAC_maxDistance or 18520, tostring(targetType)))
local volume = { id = world.VolumeType.SPHERE, params = { point = origin, radius = self.Config.FAC_maxDistance or 18520 } }
local function search(u)
if u:getLife() <= 1 or u:inAir() then return end
if u:getCoalition() ~= enemySide then return end
local up = u:getPoint()
local d = _distance(up, origin)
if d >= best then return end
local allowed = true
if targetType == 'vehicle' then allowed = _isVehicle(u)
elseif targetType == 'troop' then allowed = _isInfantry(u) end
if not allowed then return end
if land.isVisible({x=up.x,y=up.y+2,z=up.z}, {x=origin.x,y=origin.y+2,z=origin.z}) and u:isActive() then
best = d; nearest = u
end
end
world.searchObjects(Object.Category.UNIT, volume, search)
if nearest then
local uname = facUnit:getName()
self._currentTargets[uname] = { name = nearest:getName(), unitType = nearest:getTypeName(), unitId = nearest:getID() }
self:_announceNewTarget(facUnit, nearest, uname)
self:_createMarker(nearest, uname)
_dbg(self, string.format('Search: selected target=%s type=%s dist=%.0f', nearest:getName(), nearest:getTypeName(), best))
end
return nearest
end
function FAC:_announceNewTarget(facUnit, enemy, uname)
local col = self._markerColor[uname]
local colorStr = ({[trigger.smokeColor.Green]='GREEN',[trigger.smokeColor.Red]='RED',[trigger.smokeColor.White]='WHITE',[trigger.smokeColor.Orange]='ORANGE',[trigger.smokeColor.Blue]='BLUE'})[col or trigger.smokeColor.White] or 'WHITE'
local dms, mgrs, altM, altF, hdg, mph = _formatUnitGeo(enemy)
_dbg(self, string.format('AnnounceTarget: fac=%s target=%s code=%s mark=%s %s', self:_facName(uname), enemy:getName(), self._laserCodes[uname] or '1688', colorStr, self._markerType[uname] or 'FLARES'))
local msg = string.format('[%s lasing new target %s. CODE %s @ DMS %s MGRS %s Alt %dm/%dft\nMarked %s %s]',
self:_facName(uname), enemy:getTypeName(), self._laserCodes[uname] or '1688', dms, mgrs, altM, altF, colorStr, self._markerType[uname] or 'FLARES')
trigger.action.outTextForCoalition(facUnit:getCoalition(), msg, 10)
end
function FAC:_createMarker(enemy, uname)
local typ = self._markerType[uname] or self.Config.MarkerDefault
local col = self._markerColor[uname]
local when = (typ == 'SMOKE') and 300 or 5
self._smokeMarks[enemy:getName()] = timer.getTime() + when
local p = enemy:getPoint()
_dbg(self, string.format('CreateMarker: target=%s type=%s color=%s ttl=%.0fs', enemy:getName(), typ, tostring(col or trigger.smokeColor.White), when))
if typ == 'SMOKE' then
trigger.action.smoke({x=p.x, y=p.y+2, z=p.z}, col or trigger.smokeColor.White)
else
trigger.action.signalFlare({x=p.x, y=p.y+2, z=p.z}, col or trigger.smokeColor.White, 0)
end
end
function FAC:_cancelLase(uname)
local S = self._laserSpots[uname]
if S then
if S.ir then Spot.destroy(S.ir) end
if S.laser then Spot.destroy(S.laser) end
self._laserSpots[uname] = nil
end
end
function FAC:_laseUnit(enemy, facUnit, uname, code)
local p = enemy:getPoint()
local tgt = { x=p.x, y=p.y+2, z=p.z }
local spots = self._laserSpots[uname]
if not spots then
spots = {}
local ok, res = pcall(function()
spots.ir = Spot.createInfraRed(facUnit, {x=0,y=2,z=0}, tgt)
spots.laser = Spot.createLaser(facUnit, {x=0,y=2,z=0}, tgt, tonumber(code) or 1688)
return spots
end)
if ok then
self._laserSpots[uname] = spots
else
env.error('[FAC] Spot creation failed: '..tostring(res))
end
else
if spots.ir then spots.ir:setPoint(tgt) end
if spots.laser then spots.laser:setPoint(tgt) end
end
end
function FAC:_cleanupFac(uname)
self:_cancelLase(uname)
-- release reserved code if any
local side = (self._facUnits[uname] and self._facUnits[uname].side) or self.Side
if side then self:_releaseCode(side, uname) end
self._laserCodes[uname] = nil
self._markerType[uname] = nil
self._markerColor[uname] = nil
self._currentTargets[uname] = nil
self._facUnits[uname] = nil
self._facOnStation[uname] = nil
end
-- #endregion Auto-lase loop & target selection
-- #region Manual Scan/Select
-- Manual Scan/Select
function FAC:_scanManualList(group)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
-- Use DCS Unit position for robust coords
local du = Unit.getByName(uname)
if not du or not du:getPoint() then return end
local origin = du:getPoint()
_dbg(self, string.format('Action:ScanManual unit=%s origin=(%.0f,%.0f) radius=%d', uname, origin.x, origin.z, self.Config.FAC_maxDistance))
local enemySide = _coalitionOpposite(u:GetCoalition())
local foundAA, foundOther = {}, {}
local function ins(tbl, item) table.insert(tbl, item) end
local function cb(item)
if item:getCoalition() ~= enemySide then return end
if item:inAir() or not item:isActive() or item:getLife() <= 1 then return end
local p = item:getPoint()
if land.isVisible({x=p.x,y=p.y+2,z=p.z}, {x=origin.x,y=origin.y+2,z=origin.z}) then
if item:hasAttribute('SAM TR') or item:hasAttribute('IR Guided SAM') or item:hasAttribute('AA_flak') then
ins(foundAA, item)
else
ins(foundOther, item)
end
end
end
world.searchObjects(Object.Category.UNIT, { id=world.VolumeType.SPHERE, params={ point = origin, radius = self.Config.FAC_maxDistance } }, cb)
local list = {}
for i=1,10 do list[i] = foundAA[i] or foundOther[i] end
self._manualLists[uname] = list
_dbg(self, string.format('Action:ScanManual unit=%s results=%d', uname, #list))
-- print bearings/ranges
local gid = group:GetDCSObject() and group:GetDCSObject():getID() or nil
for i,v in ipairs(list) do
if v then
local p = v:getPoint()
local d = _distance(p, origin)
local dy, dx = p.z - origin.z, p.x - origin.x
local hdg = math.deg(math.atan2(dx, dy))
if hdg < 0 then hdg = hdg + 360 end
if gid then
trigger.action.outTextForGroup(gid, string.format('Target %d: %s Bearing %d Range %dm/%dft', i, v:getTypeName(), math.floor(hdg+0.5), math.floor(d), math.floor(d*3.28084)), 30)
local id = math.floor(timer.getTime()*1000 + i)
trigger.action.markToGroup(id, 'Target '..i..':'..v:getTypeName(), v:getPoint(), gid, false)
timer.scheduleFunction(function(mid) trigger.action.removeMark(mid) end, id, timer.getTime()+60)
end
end
end
end
function FAC:_setManualTarget(group, idx)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
_dbg(self, string.format('Action:SetManualTarget unit=%s index=%d', uname, idx))
local list = self._manualLists[uname]
if not list or not list[idx] then
MESSAGE:New('Invalid Target', 10):ToGroup(group)
return
end
local enemy = list[idx]
if enemy and enemy:getLife()>0 then
self._currentTargets[uname] = { name = enemy:getName(), unitType = enemy:getTypeName(), unitId = enemy:getID() }
self:_setOnStation(group, true)
self:_createMarker(enemy, uname)
MESSAGE:New(string.format('Designating Target %d: %s', idx, enemy:getTypeName()), 10):ToGroup(group)
_dbg(self, string.format('Action:SetManualTarget unit=%s target=%s type=%s', uname, enemy:getName(), enemy:getTypeName()))
else
MESSAGE:New(string.format('Target %d already dead', idx), 10):ToGroup(group)
_dbg(self, string.format('Action:SetManualTarget unit=%s index=%d dead', uname, idx))
end
end
function FAC:_multiStrike(group)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
local list = self._manualLists[uname] or {}
_dbg(self, string.format('Action:MultiStrike unit=%s targets=%d', uname, #list))
for _,t in ipairs(list) do if t and t:isExist() then self:_callFireMission(group, 10, 0, t) end end
end
-- #endregion Manual Scan/Select
-- #region RECCE sweep (aircraft-based)
function FAC:_recceDetect(group)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
local side = u:GetCoalition()
-- Use DCS Unit API for coordinates to avoid relying on MOOSE point methods
local du = Unit.getByName(uname)
if not du or not du:getPoint() then return end
local pos = du:getPoint()
_dbg(self, string.format('Action:RecceSweep unit=%s center=(%.0f,%.0f) radius=%d', uname, pos.x, pos.z, self.Config.RecceScanRadius))
local enemySide = _coalitionOpposite(side)
local temp = {}
local count = 0
local function cb(item)
if item:getCoalition() ~= enemySide then return end
if item:getLife() < 1 then return end
local p = item:getPoint()
if land.isVisible({x=p.x,y=p.y+2,z=p.z}, {x=pos.x,y=pos.y+2,z=pos.z}) and (item:isActive() or item:getCategory()==Object.Category.STATIC) then
count = count + 1
local dms, mgrs, altM, altF, hdg, mph = _formatUnitGeo(item)
local id = math.floor(timer.getTime()*1000 + count)
local text = string.format('%s - DMS %s Alt %dm/%dft\nHeading %d Speed %d MPH\nSpotted by %s', item:getTypeName(), dms, altM, altF, hdg, mph, self:_facName(uname))
trigger.action.markToCoalition(id, text, item:getPoint(), side, false)
timer.scheduleFunction(function(mid) trigger.action.removeMark(mid) end, id, timer.getTime()+300)
table.insert(temp, item)
end
end
world.searchObjects(Object.Category.UNIT, { id=world.VolumeType.SPHERE, params={ point = pos, radius = self.Config.RecceScanRadius } }, cb)
world.searchObjects(Object.Category.STATIC, { id=world.VolumeType.SPHERE, params={ point = pos, radius = self.Config.RecceScanRadius } }, cb)
self._manualLists[uname] = temp
_dbg(self, string.format('Action:RecceSweep unit=%s results=%d', uname, #temp))
if #temp > 0 then
MESSAGE:New(string.format('RECCE: %d contact(s) marked on map for 5 minutes. Open F10 Map to view.', #temp), 10):ToGroup(group)
-- Coalition heads-up so other players know to check the map
local lat, lon = coord.LOtoLL(pos)
local mgrs = _mgrsToString(coord.LLtoMGRS(lat, lon))
local loc = (mgrs and mgrs ~= '') and ('MGRS '..mgrs) or 'FAC position'
trigger.action.outTextForCoalition(side, string.format('RECCE: %d contact(s) marked near %s. Check F10 map.', #temp, loc), 10)
else
MESSAGE:New('RECCE: No visible enemy contacts found.', 8):ToGroup(group)
end
end
function FAC:_executeRecceMark(pos, coal)
-- Find nearest AI recce unit of coalition not busy, task to fly over and run recceDetect via script action
-- For simplicity we just run a coalition-wide recce flood at mark point using nearby AI if available; else no-op.
trigger.action.outTextForCoalition(coal, 'RECCE task requested at map mark', 10)
end
-- #endregion RECCE sweep (aircraft-based)
-- #region Artillery/Naval/Air tasking
function FAC:_artyAmmo(units)
local total = 0
for i=1,#units do
local ammo = units[i]:getAmmo()
if ammo then
if ammo[1] then total = total + (ammo[1].count or 0) end
end
end
return total
end
function FAC:_guidedAmmo(units)
local total = 0
for i=1,#units do
local ammo = units[i]:getAmmo()
if ammo then
for k=1,#ammo do
local d = ammo[k].desc
if d and d.guidance == 1 then total = total + (ammo[k].count or 0) end
end
end
end
return total
end
function FAC:_navalGunStats(units)
local total, maxRange = 0, 0
for i=1,#units do
local ammo = units[i]:getAmmo()
if ammo then
for k=1,#ammo do
local d = ammo[k].desc
if d and d.category == 0 and d.warhead and d.warhead.caliber and d.warhead.caliber >= 75 then
total = total + (ammo[k].count or 0)
local r = (d.warhead.caliber >= 120) and 22222 or 18000
if r > maxRange then maxRange = r end
end
end
end
end
return total, maxRange
end
function FAC:_getArtyFor(point, facUnit, mode)
-- mode: 0 HE, 1 illum, 2 mortar only, 3 heavy only (no smart), 4 guided/naval/air, -1 any except bombers
-- Accept either a MOOSE Unit (GetCoalition) or a DCS Unit (getCoalition)
local side
if facUnit then
if facUnit.GetCoalition then
side = facUnit:GetCoalition()
elseif facUnit.getCoalition then
side = facUnit:getCoalition()
end
end
side = side or self.Side
local bestName
local candidates = {}
local function consider(found)
if found:getCoalition() ~= side or not found:isActive() or found:getPlayerName() then return end
local u = found
local g = u:getGroup()
if not g then return end
local gname = g:getName()
if candidates[gname] then return end
if not self._ArtyTasked[gname] then self._ArtyTasked[gname] = { name=gname, tasked=0, timeTasked=nil, tgt=nil, requestor=nil } end
if self._ArtyTasked[gname].tasked ~= 0 and (mode ~= -1 or self._ArtyTasked[gname].requestor ~= 'AI Spotter') then return end
table.insert(candidates, gname)
end
world.searchObjects(Object.Category.UNIT, { id=world.VolumeType.SPHERE, params={ point = point, radius = 4600000 } }, consider)
local filtered = {}
for _,gname in ipairs(candidates) do
local g = Group.getByName(gname)
if g and g:isExist() then
local u1 = g:getUnit(1)
local pos = u1:getPoint()
local d = _distance(pos, point)
if mode == 4 then
if _isBomberOrFighter(u1) or _isNavalUnit(u1) then
if _isNavalUnit(u1) then
local tot, rng = self:_navalGunStats(g:getUnits())
if self.Config.Debug then _dbg(self, string.format('ArtySelect: %s (naval) dist=%.0f max=%.0f ammo=%d %s', gname, d, rng, tot or 0, (tot>0 and rng>=d) and 'OK' or 'SKIP')) end
if tot>0 and rng >= d then table.insert(filtered, gname) end
else
local guided = self:_guidedAmmo(g:getUnits())
if self.Config.Debug then _dbg(self, string.format('ArtySelect: %s (air) dist=%.0f guided=%d %s', gname, d, guided or 0, (guided>0) and 'OK' or 'SKIP')) end
if guided > 0 then table.insert(filtered, gname) end
end
end
else
if _isNavalUnit(u1) then
local tot, rng = self:_navalGunStats(g:getUnits())
if self.Config.Debug then _dbg(self, string.format('ArtySelect: %s (naval) dist=%.0f max=%.0f ammo=%d %s', gname, d, rng, tot or 0, (tot>0 and rng>=d) and 'OK' or 'SKIP')) end
if tot>0 and rng >= d then table.insert(filtered, gname) end
elseif _isArtilleryUnit(u1) then
local r = _artyMaxRangeForUnit(u1)
if self.Config.Debug then _dbg(self, string.format('ArtySelect: %s (artillery %s) dist=%.0f max=%.0f %s', gname, u1:getTypeName() or '?', d, r, (d<=r) and 'OK' or 'SKIP')) end
if d <= r then table.insert(filtered, gname) end
end
end
end
end
local best, bestAmmo
for _,gname in ipairs(filtered) do
local g = Group.getByName(gname)
local ammo = self:_artyAmmo(g:getUnits())
if (not bestAmmo) or ammo > bestAmmo then bestAmmo = ammo; best = gname end
end
if best then return Group.getByName(best) end
return nil
end
function FAC:_checkArty(group)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
-- Resolve using DCS Unit position
local du = Unit.getByName(u:GetName())
if not du or not du:getPoint() then return end
local pos = du:getPoint()
_dbg(self, string.format('Action:CheckArty unit=%s at=(%.0f,%.0f)', u:GetName(), pos.x, pos.z))
local g = self:_getArtyFor(pos, du, 0)
if g then
_dbg(self, string.format('Action:CheckArty unit=%s found=%s', u:GetName(), g:getName()))
MESSAGE:New('Arty available: '..g:getName(), 10):ToGroup(group)
else
_dbg(self, string.format('Action:CheckArty unit=%s none-found', u:GetName()))
MESSAGE:New('No untasked arty/bomber/naval in range', 10):ToGroup(group)
end
end
function FAC:_callFireMission(group, rounds, mode, specificTarget)
-- Resolve a suitable asset (arty/naval/air) and push a task at the current target or a forward offset
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
local enemy = specificTarget or (self._currentTargets[uname] and Unit.getByName(self._currentTargets[uname].name))
local attackPoint
if enemy and enemy:isActive() then attackPoint = enemy:getPoint() else
-- offset forward of FAC as fallback
local du = Unit.getByName(uname)
if not du or not du:getPoint() then return end
local hdg = _getHeading(du)
local up = du:getPoint()
attackPoint = { x = up.x + math.cos(hdg)*self.Config.facOffsetDist, y = up.y, z = up.z + math.sin(hdg)*self.Config.facOffsetDist }
end
_dbg(self, string.format('Action:CallFireMission unit=%s rounds=%s mode=%s target=%s', uname, tostring(rounds), tostring(mode), enemy and enemy:getName() or 'offset'))
local arty = self:_getArtyFor(attackPoint, Unit.getByName(uname), mode)
if not arty then
_dbg(self, string.format('Action:CallFireMission unit=%s no-asset-in-range', uname))
MESSAGE:New('Unable to process fire mission: no asset in range', 10):ToGroup(group)
return
end
local firepoint = { x = attackPoint.x, y = attackPoint.z, altitude = arty:getUnit(1):getPoint().y, altitudeEnabled = true, attackQty = 1, expend = 'One', weaponType = 268402702 }
local task
if _isNavalUnit(arty:getUnit(1)) then
-- FireAtPoint expects a 2D vec2 where y=z; do not pass altitude here
task = { id='FireAtPoint', params = { point = { x = attackPoint.x, y = attackPoint.z }, expendQty = 1, radius = 50, weaponType = 0 } }
elseif _isBomberOrFighter(arty:getUnit(1)) then
task = { id='Bombing', params = { y = attackPoint.z, x = attackPoint.x, altitude = firepoint.altitude, altitudeEnabled = true, attackQty = 1, groupAttack = true, weaponType = 2147485694 } }
else
-- Ground artillery
task = { id='FireAtPoint', params = { point = { x = attackPoint.x, y = attackPoint.z }, expendQty = rounds or 1, radius = 50, weaponType = 0 } }
end
local ctrl = arty:getController()
ctrl:pushTask(task)
-- Avoid forcing unknown option ids; rely on group's ROE/AlarmState from mission editor
local ammo = self:_artyAmmo(arty:getUnits())
self._ArtyTasked[arty:getName()] = { name = arty:getName(), tasked = rounds or 1, timeTasked = timer.getTime(), tgt = enemy, requestor = self:_facName(uname) }
trigger.action.outTextForCoalition(u:GetCoalition(), string.format('Fire mission sent: %s firing %d rounds. Requestor: %s', arty:getUnit(1):getTypeName(), rounds or 1, self:_facName(uname)), 10)
_dbg(self, string.format('Action:CallFireMission unit=%s asset=%s rounds=%s point=(%.0f,%.0f)', uname, arty:getName(), tostring(rounds), attackPoint.x, attackPoint.z))
end
function FAC:_callFireMissionMulti(group, rounds, mode)
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
local list = self._manualLists[uname]
if not list or #list == 0 then MESSAGE:New('No manual targets. Scan first.', 10):ToGroup(group) return end
local first = list[1]
local arty = self:_getArtyFor(first:getPoint(), u, 4)
if not arty then MESSAGE:New('No guided asset available', 10):ToGroup(group) return end
_dbg(self, string.format('Action:CallFireMissionMulti unit=%s targets=%d asset=%s', uname, #list, arty:getName()))
local tasks = {}
local guided = self:_guidedAmmo(arty:getUnits())
for i,t in ipairs(list) do
if i > guided then break end
local p = t:getPoint()
tasks[#tasks+1] = { number = i, id='Bombing', enabled=true, auto=false, params={ y=p.z, x=p.x, altitude=arty:getUnit(1):getPoint().y, altitudeEnabled=true, attackQty=1, groupAttack=false, weaponType=8589934592 } }
end
local combo = { id='ComboTask', params = { tasks = tasks } }
local ctrl = arty:getController()
ctrl:setOption(1,1)
ctrl:pushTask(combo)
ctrl:setOption(10,3221225470)
self._ArtyTasked[arty:getName()] = { name=arty:getName(), tasked = #tasks, timeTasked=timer.getTime(), tgt=nil, requestor=self:_facName(uname) }
trigger.action.outTextForCoalition(u:GetCoalition(), string.format('Guided strike queued on %d targets', #tasks), 10)
_dbg(self, string.format('Action:CallFireMissionMulti unit=%s queuedTasks=%d', uname, #tasks))
end
function FAC:_callCarpetOnCurrent(group)
-- Carpet bomb the current target using attack heading of the aircraft
local u = group:GetUnit(1); if not u or not u:IsAlive() then return end
local uname = u:GetName()
local tgt = self._currentTargets[uname]
if not tgt then MESSAGE:New('No current target', 10):ToGroup(group) return end
local enemy = Unit.getByName(tgt.name)
if not enemy or not enemy:isActive() then MESSAGE:New('Target invalid', 10):ToGroup(group) return end
local du = Unit.getByName(uname)
local attackHdgDeg = du and math.floor(_getHeading(du)*180/math.pi) or 0
_dbg(self, string.format('Action:Carpet unit=%s target=%s hdg=%d', uname, enemy:getName(), attackHdgDeg))
self:_executeCarpetOrTALD(enemy:getPoint(), u:GetCoalition(), 'CARPET', attackHdgDeg)
end
function FAC:_executeCarpetOrTALD(point, coal, mode, attackHeadingDeg)
local side = coal or self.Side
local arty = self:_getArtyFor(point, nil, (mode=='TALD') and 4 or 5)
if not arty then
trigger.action.outTextForCoalition(side, 'No bomber/naval asset available for '..(mode or 'CARPET'), 10)
return
end
local u1 = arty:getUnit(1)
local pos = u1:getPoint()
local hdg = attackHeadingDeg and math.rad(attackHeadingDeg) or 0
local weaponType = (mode=='TALD') and 8589934592 or 2147485694
local altitude = (mode=='TALD') and 10000 or pos.y
_dbg(self, string.format('Action:%s asset=%s heading=%d', tostring(mode or 'CARPET'), u1:getName(), attackHeadingDeg or -1))
local task = { id='Bombing', params={ x=point.x, y=point.z, altitude=altitude, altitudeEnabled=true, attackQty=1, groupAttack=true, weaponType=weaponType, direction=hdg, directionEnabled=true } }
local ctrl = arty:getController()
ctrl:setOption(1,1)
ctrl:setTask(task)
ctrl:setOption(10,3221225470)
trigger.action.outTextForCoalition(side, string.format('%s ordered to attack map mark (hdg %d)', u1:getTypeName(), attackHeadingDeg or 0), 10)
end
-- Provide a stub for periodic AI spotter loop (safe to extend as needed)
function FAC:_artyAICall()
return
end
-- #endregion Artillery/Naval/Air tasking
-- #region Mark helpers
function FAC:_markPoint(group, point, label)
local p3 = { x=point.x, y=land.getHeight({x=point.x,y=point.z}), z=point.z }
trigger.action.smoke(p3, self.Config.FAC_smokeColour_BLUE)
local id = math.floor(timer.getTime()*1000 + 0.5)
local lat, lon = coord.LOtoLL(p3)
local txt = string.format('FAC: %s at %s', label or 'Contact', _llToDMS(lat,lon))
trigger.action.markToCoalition(id, txt, p3, self.Side, true)
end
-- #region Code reservation helpers
function FAC:_reserveCode(side, uname, code)
self._reservedCodes[side] = self._reservedCodes[side] or {}
local pool = self._reservedCodes[side]
code = tostring(code)
if pool[code] and pool[code] ~= uname then
-- Find a free alternative from configured list
local fallback = self:_assignFreeCode(side, uname)
if fallback then
-- Inform coalition about reassignment
trigger.action.outTextForCoalition(side, string.format('FAC %s requested code %s but it is in use by %s. Assigned %s instead.', self:_facName(uname), code, self:_facName(pool[code]), fallback), 10)
return fallback
end
-- No free code, keep requested (collision allowed with notice)
trigger.action.outTextForCoalition(side, string.format('FAC %s is sharing code %s with %s (no free codes).', self:_facName(uname), code, self:_facName(pool[code])), 10)
end
pool[code] = uname
return code
end
function FAC:_assignFreeCode(side, uname)
self._reservedCodes[side] = self._reservedCodes[side] or {}
local pool = self._reservedCodes[side]
for _,c in ipairs(self.Config.FAC_laser_codes or {'1688'}) do
local key = tostring(c)
if not pool[key] or pool[key] == uname then
pool[key] = uname
return key
end
end
return nil
end
function FAC:_releaseCode(side, uname)
local pool = self._reservedCodes[side]
if not pool then return end
for code,owner in pairs(pool) do
if owner == uname then pool[code] = nil end
end
end
function FAC:_showCodesCoalition()
local side = self.Side
local pool = self._reservedCodes[side] or {}
local lines = {'FAC Codes In Use:\n'}
local any = false
for _,c in ipairs(self.Config.FAC_laser_codes or {'1688'}) do
local owner = pool[tostring(c)]
if owner then any = true; table.insert(lines, string.format(' %s -> %s', tostring(c), self:_facName(owner))) end
end
if not any then table.insert(lines, ' (none)') end
trigger.action.outTextForCoalition(side, table.concat(lines, '\n'), 15)
end
-- #endregion Code reservation helpers
-- #endregion Mark helpers
-- #region Export
_MOOSE_CTLD_FAC = FAC
return FAC
-- #endregion Export