-- 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 ) - 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.2.0' -- 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) -- Coalition-level Admin/Help menu o:_ensureCoalitionMenu() 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 z = ZONE_RADIUS:New(def.name or ('FAC_ZONE_'..math.random(10000,99999)), VECTOR2:New(def.coord.x, def.coord.z), 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() -- Create a coalition-level Admin/Help menu regardless of per-group menus if self._coalitionMenus[self.Side] then return end local root = MENU_COALITION:New(self.Side, 'FAC Admin/Help') 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') -- Status & On-Station MENU_GROUP_COMMAND:New(group, 'FAC: Status', root, function() self:_showFacStatus(group) end) local tgtRoot = MENU_GROUP:New(group, 'Targeting Mode', root) MENU_GROUP_COMMAND:New(group, 'Auto Laze ON', tgtRoot, function() self:_setOnStation(group, true) end) MENU_GROUP_COMMAND:New(group, 'Auto Laze OFF', tgtRoot, function() self:_setOnStation(group, nil) end) MENU_GROUP_COMMAND:New(group, '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 MENU_GROUP_COMMAND:New(group, 'Target '..i, selRoot, function() self:_setManualTarget(group, i) end) end MENU_GROUP_COMMAND:New(group, '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 MENU_GROUP_COMMAND:New(group, 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 MENU_GROUP_COMMAND:New(group, 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 MENU_GROUP_COMMAND:New(group, 'GREEN', sm, setM('SMOKE', trigger.smokeColor.Green)) MENU_GROUP_COMMAND:New(group, 'RED', sm, setM('SMOKE', trigger.smokeColor.Red)) MENU_GROUP_COMMAND:New(group, 'WHITE', sm, setM('SMOKE', trigger.smokeColor.White)) MENU_GROUP_COMMAND:New(group, 'ORANGE', sm, setM('SMOKE', trigger.smokeColor.Orange)) MENU_GROUP_COMMAND:New(group, 'BLUE', sm, setM('SMOKE', trigger.smokeColor.Blue)) MENU_GROUP_COMMAND:New(group, 'GREEN', fl, setM('FLARES', trigger.smokeColor.Green)) MENU_GROUP_COMMAND:New(group, 'WHITE', fl, setM('FLARES', trigger.smokeColor.White)) MENU_GROUP_COMMAND:New(group, 'ORANGE', fl, setM('FLARES', trigger.smokeColor.Orange)) MENU_GROUP_COMMAND:New(group, 'Map Marker current target', mk, function() self:_setMapMarker(group) end) -- Artillery local arty = MENU_GROUP:New(group, 'Artillery', root) MENU_GROUP_COMMAND:New(group, 'Check available arty', arty, function() self:_checkArty(group) end) MENU_GROUP_COMMAND:New(group, 'Call Fire Mission (HE)', arty, function() self:_callFireMission(group, self.Config.fireMissionRounds, 0) end) MENU_GROUP_COMMAND:New(group, 'Call Illumination', arty, function() self:_callFireMission(group, self.Config.fireMissionRounds, 1) end) MENU_GROUP_COMMAND:New(group, 'Call Mortar Only (anti-infantry)', arty, function() self:_callFireMission(group, self.Config.fireMissionRounds, 2) end) MENU_GROUP_COMMAND:New(group, 'Call Heavy Only (no smart)', arty, function() self:_callFireMission(group, 10, 3) end) local air = MENU_GROUP:New(group, 'Air/Naval', arty) MENU_GROUP_COMMAND:New(group, 'Single Target (GPS/Guided)', air, function() self:_callFireMission(group, 1, 4) end) MENU_GROUP_COMMAND:New(group, 'Multi Target (Guided only)', air, function() self:_callFireMissionMulti(group, 1, 4) end) MENU_GROUP_COMMAND:New(group, 'Carpet Bomb (attack heading = aircraft heading)', air, function() self:_callCarpetOnCurrent(group) end) -- RECCE MENU_GROUP_COMMAND:New(group, 'RECCE: Sweep & Mark', root, function() self:_recceDetect(group) end) -- Debug controls (mission-maker convenience; per-instance toggle) local dbg = MENU_GROUP:New(group, 'Debug', root) MENU_GROUP_COMMAND:New(group, '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) MENU_GROUP_COMMAND:New(group, '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) -- Append code summary across active FACs for situational awareness local codes = self._reservedCodes[side] or {} local any = false local summary = '\nCodes in use:\n' for _,code in ipairs(self.Config.FAC_laser_codes or {}) do local owner = codes[tostring(code)] if owner then any = true summary = summary .. string.format(' %s -> %s\n', tostring(code), self:_facName(owner)) end end if any then msg = msg .. summary end 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