-- AEGIS IADS - Event-Driven Integrated Air Defense for DCS World
-- Copyright (C) 2026 VMFA(AW)-224 Skunkworks
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program. If not, see .
--[[
================================================================================
AEGIS IADS v0.8.4 -- Event-Driven Integrated Air Defense for DCS World
Phase 1: EW-driven activation, WEZ gating, altitude filtering, PD slaving
Phase 2: Infrastructure dependencies (power, C2), EMCON cycling
Phase 3: HARM detection (S_EVENT_SHOT), GO_DARK reaction, cooldown timer
Phase 3.2: HARM reaction policies (selfProtect, LAST_DITCH, panic, multi-HARM)
Phase 5: PB HARM network warning (trajectory + EW detection delay + harmInbound)
Phase 3.3: PD improvements (alert frustration, bravery roll, orphan promotion)
Phase 6.0: EA jammer framework (AI + player EA, jammed EMCON cycling)
Phase 6.1: EA v2 — burn-through formula, EW contact filtering, sector jam warning,
WSO F10 menu (mode selection, pod management, emitter alerts)
Phase 6.3: Home-on-Jam (HOJ immunity window) + differentiated jammed EMCON timing
Phase 6.5: EW AoE lift, gain-scaled range, bearing-aware sector EMCON
Phase 6.6: Unified EW burn-through (β formula), observable sector jam flag,
nearest-EW bearing gate, per-system trackingBias, HERC jammer
Companion: IADS visualizer (DUMP protocol + bridge.py + web UI)
+ EMCON jitter (startup delay, threat memory, quick peek, double-sweep, spook)
+ Group name zone/range overrides (SAM-SA10-NORTH-1-NEZ25)
+ Squared distance optimization (no sqrt in WEZ checks)
+ EW detection range override (EW-NORTH-DET120)
Dependencies: NONE (pure DCS scripting engine, no MOOSE/MIST required)
Naming Conventions (auto-discovery):
EW Radars: EW-{SECTOR}[-{ID}][-DET{NM}] e.g. EW-NORTH, EW-NORTH-2, EW-NORTH-DET120
SAM Sites: SAM-TYPE-SECTOR[-ID][-ZONE(NM)][-ACT(NM)] e.g. SAM-SA10-NORTH-1-NEZ25-ACT50
Point Defense: PD-{TYPE}-{SECTOR}[-{ID}] e.g. PD-SA15-NORTH-1
Power Sources: PWR-{TARGET} e.g. PWR-SA5-SOUTH-1 -> SAM-SA5-SOUTH-1
PWR-EW-NORTH -> EW-NORTH
Command Centers:CMD-{SECTOR}[-{ID}] e.g. CMD-SOUTH
EA Aircraft: EA-{TYPE}-{ANYTHING}[-{ID}] e.g. EA-GROWLER-BENGAL-1 (opposing coalition)
Missile Variant Suffixes (S-300V family):
SAM-SA12-NORTH-1 Gladiator default (41 NM WEZ)
SAM-SA12G-NORTH-1 Giant loadout (54 NM WEZ)
SAM-SA23-EAST-1 S-300VM Gladiator (54 NM WEZ)
SAM-SA23G-EAST-1 S-300VM Giant (108 NM WEZ)
SAM-SA23V4-EAST-1 S-300V4 Gladiator (81 NM WEZ)
SAM-SA23V4G-EAST-1 S-300V4 Giant (205 NM WEZ)
Zone and Activation Override Examples:
SAM-SA10-NORTH-1 Uses global default (WEZ 40 NM, ACT 50 NM)
SAM-SA10-NORTH-1-NEZ NEZ with database default (20 NM)
SAM-SA10-NORTH-1-NEZ25 NEZ at 25 NM
SAM-SA10-NORTH-1-WEZ30 WEZ override at 30 NM
SAM-SA10-NORTH-1-ACT60 Default WEZ, activation at 60 NM
SAM-SA2-NORTH-1-NEZ-ACT30 NEZ + activation at 30 NM
SAM-SA6-SOUTH-NEZ No ID, just NEZ (suffixes are order-independent)
Author: VMFA(AW)-224 Skunkworks / Claude collaboration
Version: 0.8.4
================================================================================
--]]
AEGIS = {}
AEGIS.__index = AEGIS
AEGIS.Version = "0.8.4"
---------------------------------------------------------------------------
-- SYSTEM DATABASE
-- WEZ/NEZ in NM, altitude in feet, type determines behavior
---------------------------------------------------------------------------
AEGIS.SYSTEM_DB = {
-- Type WEZ NEZ ActRange AltMin AltMax Category NeedsPwr SelfProtect TrackRadar (DCS type name) CommandPost (DCS type name) srLabel (ESM display)
--
-- Base DCS systems
SA2 = { wez=24, nez=10, actRange=30, altMin=150, altMax=80000, cat="AREA", needsPower=false, selfProtect=false, trackRadar="SNR_75V", srLabel="Fan Song", trackingBias=2.76 }, -- TR/Eff: SNR-75 64 NM / 23.2 NM
SA3 = { wez=10, nez=5, actRange=14, altMin=600, altMax=80000, cat="AREA", needsPower=false, selfProtect=false, trackRadar="snr s-125 tr", srLabel="Low Blow", trackingBias=3.19 }, -- TR/Eff: SNR-125 43 NM / 13.5 NM
SA5 = { wez=125, nez=60, actRange=150, altMin=1000, altMax=100000, cat="AREA", needsPower=true, selfProtect=false, trackRadar="RPC_5N62V", srLabel="Square Pair", trackingBias=1.67 },
SA6 = { wez=14, nez=5, actRange=18, altMin=60, altMax=26000, cat="AREA", needsPower=false, selfProtect=false, trackRadar="Kub 1S91 str", srLabel="Straight Flush", trackingBias=2.96 }, -- TR/Eff: Straight Flush 40 NM / 13.5 NM
SA8 = { wez=7, nez=3.5, actRange=9, altMin=30, altMax=16500, cat="SHORAD", needsPower=false, selfProtect=false, srLabel="SA-8", trackingBias=2.89 }, -- TR/Eff: 16.2 NM / 5.6 NM
SA10 = { wez=39, nez=20, actRange=50, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300PS 40B6M tr", srLabel="Big Bird", trackingBias=2.12, homeOnJam=true }, -- TR/Eff: Flap Lid 86 NM / 40.5 NM
SA11 = { wez=25, nez=12, actRange=30, altMin=10, altMax=75000, cat="AREA", needsPower=false, selfProtect=true, srLabel="Fire Dome", trackingBias=1.67, homeOnJam=true }, -- TR/Eff: TELAR 45 NM / 27.0 NM
SA15 = { wez=8, nez=3, actRange=10, altMin=10, altMax=20000, cat="PD", needsPower=false, selfProtect=true, srLabel="SA-15", trackingBias=2.08 }, -- TR/Eff: 13.5 NM / 6.5 NM
SA13 = { wez=2.8, nez=1.4, actRange=4, altMin=33, altMax=11500, cat="PD", needsPower=false, selfProtect=false, srLabel="SA-13", trackingBias=1.0 },
SA19 = { wez=4.4, nez=2, actRange=6, altMin=15, altMax=11500, cat="PD", needsPower=false, selfProtect=false, srLabel="SA-19", trackingBias=2.26 }, -- TR/Eff: 9.7 NM / 4.3 NM
HAWK = { wez=25, nez=12, actRange=30, altMin=150, altMax=45000, cat="AREA", needsPower=false, selfProtect=false, trackRadar="Hawk tr", srLabel="Hawk SR", trackingBias=2.00 }, -- TR/Eff: MPQ-46 48.6 NM / 24.3 NM
PATRIOT = { wez=80, nez=35, actRange=95, altMin=200, altMax=80000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="Patriot str", srLabel="Patriot", trackingBias=1.33, homeOnJam=true }, -- TR/Eff: MPQ-65 108 NM / 81.0 NM
NASAMS = { wez=10, nez=5, actRange=12, altMin=100, altMax=50000, cat="AREA", needsPower=false, selfProtect=false, srLabel="Sentinel", trackingBias=1.0 },
GEPARD = { wez=2, nez=1, actRange=3, altMin=15, altMax=10000, cat="PD", needsPower=false, selfProtect=false, trackingBias=1.0 },
SHILKA = { wez=1.5, nez=0.5, actRange=2, altMin=0, altMax=10000, cat="PD", needsPower=false, selfProtect=false, trackingBias=1.0 },
ROLAND = { wez=4, nez=2, actRange=5, altMin=50, altMax=16000, cat="PD", needsPower=false, selfProtect=false, trackingBias=1.0 },
RAPIER = { wez=3, nez=1.5, actRange=4, altMin=50, altMax=10000, cat="PD", needsPower=false, selfProtect=false, trackingBias=1.0 },
--
-- CurrentHill mod
SA15CH = { wez=9, nez=3, actRange=11, altMin=10, altMax=33000, cat="PD", needsPower=false, selfProtect=true, trackingBias=2.01 }, -- TR/Eff: Tor-M2 17.3 NM / 8.6 NM
SA22 = { wez=11, nez=5, actRange=14, altMin=15, altMax=49000, cat="PD", needsPower=false, selfProtect=true, srLabel="Pantsir", trackingBias=1.80 }, -- TR/Eff: 19.4 NM / 10.8 NM
--
-- High Digit SAMs mod (https://github.com/Auranis/HighDigitSAMs)
-- WEZ/NEZ values are real-world estimates pending SME/in-game verification
SA12 = { wez=41, nez=20, actRange=50, altMin=82, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300V 9S32 tr", commandPost="S-300V 9S457 cp", srLabel="Bill Board", trackingBias=2.00, homeOnJam=true }, -- TR/Eff: Grill Pan 81 NM / 40.5 NM. Use SA12G for Giant
SA12G = { wez=54, nez=25, actRange=64, altMin=82, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300V 9S32 tr", commandPost="S-300V 9S457 cp", srLabel="Bill Board", trackingBias=1.50, homeOnJam=true }, -- TR/Eff: Grill Pan 81 NM / 54.0 NM
SA17 = { wez=27, nez=12, actRange=30, altMin=30, altMax=75000, cat="AREA", needsPower=false, selfProtect=true, srLabel="Chair Back", trackingBias=2.40, homeOnJam=true }, -- TR/Eff: TELAR 64.8 NM / 27.0 NM
SA20A = { wez=81, nez=40, actRange=95, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300PMU1 30N6E tr", commandPost="S-300PMU1 54K6 cp", srLabel="Big Bird", trackingBias=1.48, homeOnJam=true }, -- TR/Eff: Tomb Stone 120 NM / 81.0 NM
SA20B = { wez=109, nez=50, actRange=120, altMin=33, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300PMU2 30N6E2 mast tr", commandPost="S-300PMU2 54K6E2 cp", srLabel="Big Bird", trackingBias=1.94, homeOnJam=true }, -- TR/Eff: Tomb Stone 210 NM / 108.0 NM
SA21 = { wez=105, nez=50, actRange=130, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-400 92N6E mast tr", commandPost="S-400 55K6 cp", srLabel="Big Bird", trackingBias=1.56, homeOnJam=true }, -- TR/Eff: Tomb Stone 210 NM / 135.0 NM
SA23 = { wez=54, nez=25, actRange=64, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300VM 9S32ME tr", commandPost="S-300VM 9S457ME cp", srLabel="Bill Board", trackingBias=2.48, homeOnJam=true }, -- TR/Eff: Grill Screen 134 NM / 54.0 NM. Use SA23G for Giant
SA23G = { wez=108, nez=65, actRange=130, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300VM 9S32ME tr", commandPost="S-300VM 9S457ME cp", srLabel="Bill Board", trackingBias=1.24, homeOnJam=true }, -- TR/Eff: Grill Screen 134 NM / 108.0 NM
SA23V4 = { wez=81, nez=40, actRange=95, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300V4 9S32M-1E tr", commandPost="S-300V4 9S457-2E cp", srLabel="Bill Board", trackingBias=2.67, homeOnJam=true }, -- TR/Eff: V4 Grill Screen 216 NM / 81.0 NM. trackRadar UNVERIFIED
SA23V4G = { wez=205, nez=100, actRange=220, altMin=50, altMax=100000, cat="AREA", needsPower=false, selfProtect=true, trackRadar="S-300V4 9S32M-1E tr", commandPost="S-300V4 9S457-2E cp", srLabel="Bill Board", trackingBias=1.05, homeOnJam=true }, -- TR/Eff: V4 Grill Screen 216 NM / 205.2 NM. trackRadar UNVERIFIED
SAMPT = { wez=65, nez=30, actRange=75, altMin=100, altMax=80000, cat="AREA", needsPower=false, selfProtect=true, commandPost="SAMPT_ME", srLabel="ARABEL", trackingBias=1.0 }, -- SAMP/T Aster 30, ARH fire-and-forget. TR name UNVERIFIED
}
-- Fallback for unknown system types
AEGIS.SYSTEM_DB.UNKNOWN = { wez=15, nez=7, actRange=20, altMin=50, altMax=60000, cat="AREA", needsPower=false, selfProtect=false, trackingBias=1.0 }
---------------------------------------------------------------------------
-- JAMMER BASELINE (shared tuning knobs for all EA aircraft)
-- All EA types share this baseline; per-type JAMMER_DB provides a mult.
---------------------------------------------------------------------------
AEGIS.JAMMER_BASELINE = {
effectRange = 60, -- NM: hard AoE cap (SAM layer). Nothing beyond this is affected.
pods = 2, -- full omni = both, split = 1 omni + 1 directional
burnThroughRatio = 0.35, -- SAM layer: baseline fraction of target's refRange
burnExponent = 0.5, -- SAM layer: √Rj (physics), tunable for gameplay
ewBeta = 1.8, -- EW ECCM capability constant (√NM units, ~25 dB processing gain)
omniHalfAngle = 90, -- degrees, OMNI mode beam half-angle
wideHalfAngle = 35, -- degrees, WIDE mode cone pod half-angle
dirHalfAngle = 5, -- degrees, directional beam half-angle
-- Gain multipliers auto-derived from angles at Init via _BeamGain()
directionalRangeMult = 2.0, -- directional reach: effectRange × this = 120 NM
omniPieHalfWidth = 0.436, -- ±25° in radians (EW contact filtering pie)
directionalPieHalfWidth = 1.047, -- ±60° in radians (EW contact filtering pie)
rangeGainScale = 0.35, -- range scales with gain: tighter beam = more reach
samTrackingBias = 1.0, -- SAM tracking radar fallback (per-system trackingBias in SYSTEM_DB overrides)
ewPieRefDist = 45, -- NM: EW pie at full width inside this range, shrinks linearly beyond
esmRevealChance = 0.10, -- Base probability per cycle to advance range confidence
esmRevealRefDist = 45, -- NM: range reveal rolls at full chance inside this distance
esmDecayInterval = 150, -- Seconds per confidence level lost while emitter is stale
}
--- Compute antenna gain multiplier from beam half-angle.
--- Physics: Gain ~ 4pi/Omega. Compressed for gameplay: 0.4 / (1 - cos(theta))^0.29
--- Anchored at ±90 deg = 0.4x (full sphere nerf).
function AEGIS._BeamGain(halfAngleDeg)
return 0.4 / math.pow(1 - math.cos(math.rad(halfAngleDeg)), 0.29)
end
--- WIDE mode presets: selectable beam widths.
--- Gain auto-computed at Init from AEGIS._BeamGain().
--- Pie: EW contact masking half-width. More focused beam = more power at EW = wider shadow.
AEGIS.WIDE_PRESETS = {
{ label = "W90", angle = 45, pieDeg = 30 }, -- wide, ~0.61x gain, ±30° pie
{ label = "W70", angle = 35, pieDeg = 37 }, -- default, ~0.68x gain, ±37° pie
{ label = "W50", angle = 25, pieDeg = 45 }, -- tight, ~0.80x gain, ±45° pie
}
---------------------------------------------------------------------------
-- JAMMER DATABASE (per-type multiplier)
-- Higher mult = more effective jammer = harder for radar to burn through.
---------------------------------------------------------------------------
AEGIS.JAMMER_DB = {
GROWLER = { mult = 1.0 }, -- baseline (EA-18G)
HERC = { mult = 1.3 }, -- EC-130H Compass Call — more power, bigger airframe
}
AEGIS.JAMMER_DB.UNKNOWN = { mult = 0.5 }
---------------------------------------------------------------------------
-- STATES
---------------------------------------------------------------------------
AEGIS.STATE = {
DARK = "DARK", -- No threat, emissions OFF
AWARE = "AWARE", -- EW has contacts but not in my WEZ (still dark)
ALERT = "ALERT", -- Contact in WEZ, fully hot
EMCON_ON = "EMCON_ON", -- EMCON active: emissions silent
EMCON_OFF = "EMCON_OFF", -- EMCON lifted: radar on, searching
EMCON_ENGAGED = "EMCON_ENGAGED", -- Was EMCON, found target, weapons free
DESTROYED = "DESTROYED", -- Dead
}
---------------------------------------------------------------------------
-- CONSTANTS
---------------------------------------------------------------------------
AEGIS.EW_POLL_INTERVAL = 10 -- Seconds between EW polls
AEGIS.ALERT_TIMEOUT = 60 -- Seconds after last WEZ contact before going dark
AEGIS.EMCON_ON_MIN = 30 -- EMCON ON (silent) phase min seconds
AEGIS.EMCON_ON_MAX = 120 -- EMCON ON (silent) phase max seconds
AEGIS.EMCON_OFF_MIN = 15 -- EMCON OFF (sweep) phase min seconds
AEGIS.EMCON_OFF_MAX = 45 -- EMCON OFF (sweep) phase max seconds
AEGIS.EMCON_DETECT_DELAY = 5 -- Seconds after radar on before checking targets
AEGIS.EMCON_REENGAGE_MIN = 10 -- Min seconds with no WEZ targets before re-entering EMCON
AEGIS.EMCON_REENGAGE_MAX = 30 -- Max seconds
AEGIS.EMCON_STARTUP_JITTER = 60 -- Max random delay before first EMCON cycle
AEGIS.EMCON_DOUBLE_SWEEP_PCT = 15 -- % chance of quick double-sweep
AEGIS.EMCON_EARLY_TERM_PCT = 20 -- % chance of cutting sweep short (quick peek)
AEGIS.EMCON_THREAT_SCALE = 0.5 -- Silent phase multiplier when threat was recently seen
AEGIS.EMCON_RELAXED_SCALE = 1.5 -- Silent phase multiplier after 3+ empty sweeps
AEGIS.EMCON_SPOOK_DURATION = 120 -- Seconds a nearby SAM death causes extended silence
AEGIS.EMCON_SPOOK_ENABLED = false -- Neighbor spook feature (off by default)
AEGIS.AUTO_ASSOCIATE_RANGE_NM = 40 -- Auto EW-to-SAM association range
AEGIS.PD_ASSOCIATE_RANGE_NM = 5 -- Auto PD-to-parent association range
AEGIS.NM_TO_M = 1852 -- Conversion factor
AEGIS.EW_SUB_INTERVAL = 0.2 -- EW sub-poll interval (200ms / 5Hz)
AEGIS.FT_TO_M = 0.3048 -- Conversion factor
-- Mobile SAM position polling
AEGIS.MOB_POLL_IDLE = 60 -- Seconds between pos updates for DARK SAMs in quiet sectors
AEGIS.MOB_POLL_ACTIVE = 20 -- Seconds between pos updates for AWARE+ SAMs
AEGIS.MOB_MOVE_THRESHOLD = 50 -- Meters of movement to trigger debug log
-- HARM detection
AEGIS.HARM_COOLDOWN = 60 -- Legacy: single cooldown value (backward compat)
AEGIS.HARM_MISSILE_CATEGORY = 6 -- DCS missileCategory for anti-radiation missiles
AEGIS.HARM_GUIDANCE = 5 -- DCS guidance value for anti-radiation
-- HARM reaction policies (Phase 3.2)
AEGIS.HARM_REACTION_DELAY_MIN = 6 -- Min seconds: detection (2-4s) + classification (2-3s) + crew action (2-3s)
AEGIS.HARM_REACTION_DELAY_MAX = 9 -- Max seconds before crew reacts
AEGIS.HARM_COOLDOWN_MIN = 45 -- Min GO_DARK cooldown (jittered)
AEGIS.HARM_COOLDOWN_MAX = 90 -- Max GO_DARK cooldown (jittered)
AEGIS.HARM_STAY_HOT_DURATION = 30 -- Seconds selfProtect SAM stays hot engaging ARM
AEGIS.HARM_LAST_DITCH_MIN = 8 -- Min seconds PD gets to engage ARM before parent goes dark
AEGIS.HARM_LAST_DITCH_MAX = 12 -- Max seconds PD gets to engage ARM
AEGIS.HARM_PANIC_PCT = 15 -- % chance selfProtect crew panics and goes dark anyway
AEGIS.HARM_MULTI_THRESHOLD_MIN = 4 -- Min per-SAM saturation threshold (randomized at init)
AEGIS.HARM_MULTI_THRESHOLD_MAX = 8 -- Max per-SAM saturation threshold (crew personality)
AEGIS.HARM_MULTI_WINDOW = 15 -- Seconds to count multiple HARMs for saturation check
AEGIS.HARM_EXTEND_INTERVAL = 15 -- Seconds between weapon-alive checks when extending cooldown
AEGIS.HARM_MAX_COOLDOWN = 180 -- Hard cap: max total seconds a HARM reaction can last (safety net)
AEGIS.HARM_BRAVERY_PCT = 5 -- % chance ANY crew stays hot against HARM (the "nat 20")
AEGIS.HARM_DETECTION_RANGE = 40 -- NM: max range SAM tracking radar detects inbound ARM (0 = unlimited)
AEGIS.HARM_SPEED = 680 -- m/s: typical AGM-88 cruise speed for detection delay computation
-- Alert frustration (Phase 3.3)
AEGIS.ALERT_FRUSTRATION_MIN = 30 -- Min seconds ALERT without WEZ contact before crew powers down
AEGIS.ALERT_FRUSTRATION_MAX = 60 -- Max seconds
AEGIS.ALERT_FRUSTRATION_STAY_PCT = 10 -- % chance crew stays hot instead (re-rolls timeout)
-- PB HARM network warning (Phase 5)
AEGIS.PB_HARM_CHECK_DELAY = 2 -- Seconds after PB launch to check trajectory (velocity stabilizes)
AEGIS.PB_HARM_WARN_RADIUS = 5 -- NM: SAMs within this distance of projected path get warned
AEGIS.PB_HARM_COOLDOWN_MARGIN = 30 -- Extra seconds added to ETA for cooldown/suppress timing
AEGIS.PB_HARM_INBOUND_MARGIN = 30 -- Extra seconds for harmInbound flag expiry past ETA
AEGIS.PB_HARM_EW_REACTION_MIN = 3 -- Min crew reaction after EW network warning (shorter than TOO/SP)
AEGIS.PB_HARM_EW_REACTION_MAX = 5 -- Max crew reaction after EW network warning
AEGIS.PB_HARM_DETECTION_THRESHOLD = 3.0 -- Cumulative EW score to establish a track
AEGIS.PB_HARM_SWEEP_PERIOD = 6 -- Seconds per EW sweep (10 RPM assumption)
AEGIS.PB_HARM_DETECTION_FLOOR = 12 -- Minimum detection delay (2 sweeps even at close range)
-- PB HARM EW detection score table: range (NM) from HARM to EW -> score per sweep
-- Multiple EWs sum scores each sweep independently
AEGIS.PB_HARM_SCORE_TABLE = {
{ maxRange = 5, score = 1.5 }, -- 12s (2 sweeps)
{ maxRange = 10, score = 1.5 }, -- 12s
{ maxRange = 15, score = 1.2 }, -- 18s
{ maxRange = 20, score = 1.0 }, -- 18s
{ maxRange = 25, score = 0.8 }, -- 24s
{ maxRange = 30, score = 0.7 }, -- 30s
{ maxRange = 35, score = 0.5 }, -- 36s
{ maxRange = 40, score = 0.3 }, -- 60s
{ maxRange = 45, score = 0.2 }, -- 90s
{ maxRange = 50, score = 0.15 }, -- 120s
{ maxRange = 55, score = 0.10 }, -- 180s
{ maxRange = 60, score = 0.07 }, -- ~4min
{ maxRange = 65, score = 0.05 }, -- ~6min
{ maxRange = 70, score = 0.03 }, -- ~10min
}
AEGIS.PB_HARM_SCORE_FLOOR = 0.01 -- 70+ NM: effectively never detects
-- EA jammer framework (Phase 6)
AEGIS.EA_ENABLED = true -- EA jammer detection enabled by default
AEGIS.JAM_DETECTION_DELAY_MIN = 1 -- Jammer ESM response time min (burn-through window)
AEGIS.JAM_DETECTION_DELAY_MAX = 3 -- Jammer ESM response time max
-- Home-on-Jam (Phase 6.3)
AEGIS.HOJ_ENABLED = true -- master toggle (disable if players hate it)
AEGIS.HOJ_BASE_PCT = 0.07 -- 7% chance per peek, escalates by +7% each consecutive peek
AEGIS.HOJ_WINDOW_MIN = 75 -- jam immunity window min (seconds)
AEGIS.HOJ_WINDOW_MAX = 120 -- jam immunity window max (seconds)
AEGIS.HOJ_COOLDOWN = 60 -- seconds after HOJ window expires before re-roll eligible
-- Differentiated jammed EMCON timing (Phase 6.3)
AEGIS.JAM_EMCON_ON_MIN_HOJ = 12 -- HOJ-capable: aggressive peek (longer on)
AEGIS.JAM_EMCON_ON_MAX_HOJ = 25
AEGIS.JAM_EMCON_OFF_MIN_HOJ = 20 -- HOJ-capable: short hide (more frequent peeks)
AEGIS.JAM_EMCON_OFF_MAX_HOJ = 45
AEGIS.JAM_EMCON_ON_MIN_STD = 5 -- Standard: cautious peek (brief on)
AEGIS.JAM_EMCON_ON_MAX_STD = 10
AEGIS.JAM_EMCON_OFF_MIN_STD = 60 -- Standard: long hide
AEGIS.JAM_EMCON_OFF_MAX_STD = 150
-- EW detection range override
AEGIS.EW_DETECTION_RANGE = 0 -- NM, 0 = no limit (DCS handles it)
-- DCS controller constants
AEGIS.ALARM = { AUTO=0, GREEN=1, RED=2 }
AEGIS.ROE = { WEAPON_FREE=0, OPEN_FIRE=2, RETURN_FIRE=3, WEAPON_HOLD=4 }
-- Map marker ID counter
AEGIS._markerId = 90000
---------------------------------------------------------------------------
-- CONSTRUCTOR
---------------------------------------------------------------------------
function AEGIS:New(side, config)
local self = setmetatable({}, AEGIS)
if side == "red" then
self.coalitionId = coalition.side.RED
elseif side == "blue" then
self.coalitionId = coalition.side.BLUE
else
env.error("[AEGIS] Invalid coalition: " .. tostring(side))
return nil
end
self.side = side
config = config or {}
self.ewPollInterval = config.ewPollInterval or AEGIS.EW_POLL_INTERVAL
self.alertTimeout = config.alertTimeout or AEGIS.ALERT_TIMEOUT
self.autoAssocRange = config.autoAssociateRange or AEGIS.AUTO_ASSOCIATE_RANGE_NM
self.pdAssocRange = config.pdAssociateRange or AEGIS.PD_ASSOCIATE_RANGE_NM
self.emconOnMin = config.emconOnMin or AEGIS.EMCON_ON_MIN
self.emconOnMax = config.emconOnMax or AEGIS.EMCON_ON_MAX
self.emconOffMin = config.emconOffMin or AEGIS.EMCON_OFF_MIN
self.emconOffMax = config.emconOffMax or AEGIS.EMCON_OFF_MAX
self.emconDetectDelay = config.emconDetectDelay or AEGIS.EMCON_DETECT_DELAY
self.emconReengageMin = config.emconReengageMin or AEGIS.EMCON_REENGAGE_MIN
self.emconReengageMax = config.emconReengageMax or AEGIS.EMCON_REENGAGE_MAX
self.emconStartupJitter = config.emconStartupJitter or AEGIS.EMCON_STARTUP_JITTER
self.emconDoubleSweep = config.emconDoubleSweepPct or AEGIS.EMCON_DOUBLE_SWEEP_PCT
self.emconEarlyTerm = config.emconEarlyTermPct or AEGIS.EMCON_EARLY_TERM_PCT
self.emconThreatScale = config.emconThreatScale or AEGIS.EMCON_THREAT_SCALE
self.emconRelaxedScale = config.emconRelaxedScale or AEGIS.EMCON_RELAXED_SCALE
self.emconSpookDuration = config.emconSpookDuration or AEGIS.EMCON_SPOOK_DURATION
self.emconSpookEnabled = config.emconSpookEnabled
if self.emconSpookEnabled == nil then self.emconSpookEnabled = AEGIS.EMCON_SPOOK_ENABLED end
self.defaultZone = config.defaultZone or "WEZ" -- "WEZ" or "NEZ"
self.harmCooldown = config.harmCooldown or AEGIS.HARM_COOLDOWN
-- HARM reaction policies (Phase 3.2)
self.harmReactionDelayMin = config.harmReactionDelayMin or AEGIS.HARM_REACTION_DELAY_MIN
self.harmReactionDelayMax = config.harmReactionDelayMax or AEGIS.HARM_REACTION_DELAY_MAX
self.harmCooldownMin = config.harmCooldownMin or AEGIS.HARM_COOLDOWN_MIN
self.harmCooldownMax = config.harmCooldownMax or AEGIS.HARM_COOLDOWN_MAX
self.harmStayHotDuration = config.harmStayHotDuration or AEGIS.HARM_STAY_HOT_DURATION
self.harmLastDitchMin = config.harmLastDitchMin or AEGIS.HARM_LAST_DITCH_MIN
self.harmLastDitchMax = config.harmLastDitchMax or AEGIS.HARM_LAST_DITCH_MAX
self.harmPanicPct = config.harmPanicPct or AEGIS.HARM_PANIC_PCT
self.harmMultiThresholdMin = config.harmMultiThresholdMin or AEGIS.HARM_MULTI_THRESHOLD_MIN
self.harmMultiThresholdMax = config.harmMultiThresholdMax or AEGIS.HARM_MULTI_THRESHOLD_MAX
self.harmMultiWindow = config.harmMultiWindow or AEGIS.HARM_MULTI_WINDOW
self.harmExtendInterval = config.harmExtendInterval or AEGIS.HARM_EXTEND_INTERVAL
self.harmMaxCooldown = config.harmMaxCooldown or AEGIS.HARM_MAX_COOLDOWN
self.harmBraveryPct = config.harmBraveryPct or AEGIS.HARM_BRAVERY_PCT
self.harmDetectionRange = config.harmDetectionRange or AEGIS.HARM_DETECTION_RANGE
-- PB HARM network warning
self.pbHarmCheckDelay = config.pbHarmCheckDelay or AEGIS.PB_HARM_CHECK_DELAY
self.pbHarmWarnRadius = config.pbHarmWarnRadius or AEGIS.PB_HARM_WARN_RADIUS
self.pbHarmCooldownMargin = config.pbHarmCooldownMargin or AEGIS.PB_HARM_COOLDOWN_MARGIN
self.pbHarmInboundMargin = config.pbHarmInboundMargin or AEGIS.PB_HARM_INBOUND_MARGIN
self.pbHarmEwReactionMin = config.pbHarmEwReactionMin or AEGIS.PB_HARM_EW_REACTION_MIN
self.pbHarmEwReactionMax = config.pbHarmEwReactionMax or AEGIS.PB_HARM_EW_REACTION_MAX
self.pbHarmDetThreshold = config.pbHarmDetThreshold or AEGIS.PB_HARM_DETECTION_THRESHOLD
self.pbHarmSweepPeriod = config.pbHarmSweepPeriod or AEGIS.PB_HARM_SWEEP_PERIOD
self.pbHarmDetFloor = config.pbHarmDetFloor or AEGIS.PB_HARM_DETECTION_FLOOR
-- Backward compat: if user set harmCooldown but not the min/max, derive jitter range
if config.harmCooldown and not config.harmCooldownMin and not config.harmCooldownMax then
self.harmCooldownMin = math.floor(self.harmCooldown * 0.75)
self.harmCooldownMax = math.floor(self.harmCooldown * 1.5)
end
-- Backward compat: old harmMultiThreshold (single number) → use as both min and max (fixed threshold)
if config.harmMultiThreshold and not config.harmMultiThresholdMin and not config.harmMultiThresholdMax then
self.harmMultiThresholdMin = config.harmMultiThreshold
self.harmMultiThresholdMax = config.harmMultiThreshold
end
-- Alert frustration (Phase 3.3)
self.alertFrustrationMin = config.alertFrustrationMin or AEGIS.ALERT_FRUSTRATION_MIN
self.alertFrustrationMax = config.alertFrustrationMax or AEGIS.ALERT_FRUSTRATION_MAX
self.alertFrustrationStayPct = config.alertFrustrationStayPct or AEGIS.ALERT_FRUSTRATION_STAY_PCT
self.debug = config.debug or false
-- EA jammer framework (Phase 6)
self.eaEnabled = config.eaEnabled
if self.eaEnabled == nil then self.eaEnabled = config.ecmEnabled end -- compat
if self.eaEnabled == nil then self.eaEnabled = AEGIS.EA_ENABLED end
self.jamDetectionDelayMin = config.jamDetectionDelayMin or AEGIS.JAM_DETECTION_DELAY_MIN
self.jamDetectionDelayMax = config.jamDetectionDelayMax or AEGIS.JAM_DETECTION_DELAY_MAX
-- Home-on-Jam (Phase 6.3)
self.hojEnabled = config.hojEnabled
if self.hojEnabled == nil then self.hojEnabled = AEGIS.HOJ_ENABLED end
self.hojBasePct = config.hojBasePct or AEGIS.HOJ_BASE_PCT
self.hojWindowMin = config.hojWindowMin or AEGIS.HOJ_WINDOW_MIN
self.hojWindowMax = config.hojWindowMax or AEGIS.HOJ_WINDOW_MAX
self.hojCooldown = config.hojCooldown or AEGIS.HOJ_COOLDOWN
-- Differentiated jammed EMCON timing
self.jamEmconOnMinHOJ = config.jamEmconOnMinHOJ or AEGIS.JAM_EMCON_ON_MIN_HOJ
self.jamEmconOnMaxHOJ = config.jamEmconOnMaxHOJ or AEGIS.JAM_EMCON_ON_MAX_HOJ
self.jamEmconOffMinHOJ = config.jamEmconOffMinHOJ or AEGIS.JAM_EMCON_OFF_MIN_HOJ
self.jamEmconOffMaxHOJ = config.jamEmconOffMaxHOJ or AEGIS.JAM_EMCON_OFF_MAX_HOJ
self.jamEmconOnMinStd = config.jamEmconOnMinStd or AEGIS.JAM_EMCON_ON_MIN_STD
self.jamEmconOnMaxStd = config.jamEmconOnMaxStd or AEGIS.JAM_EMCON_ON_MAX_STD
self.jamEmconOffMinStd = config.jamEmconOffMinStd or AEGIS.JAM_EMCON_OFF_MIN_STD
self.jamEmconOffMaxStd = config.jamEmconOffMaxStd or AEGIS.JAM_EMCON_OFF_MAX_STD
-- EW detection range override
self.ewDetectionRange = config.ewDetectionRange or AEGIS.EW_DETECTION_RANGE
-- EA display: false = generic labels (SAM/EW), true = full group name + type tag
self.eaDebugLabels = config.eaDebugLabels or false
-- EA emitter memory: seconds to retain stale emitters after they stop radiating (0 = no memory)
self.eaEmitterMemory = config.eaEmitterMemory or 60
-- Jammer baseline: merge user overrides into a copy of the class baseline
self.jammerBaseline = {}
for k, v in pairs(AEGIS.JAMMER_BASELINE) do self.jammerBaseline[k] = v end
if config.jammerBaseline then
for k, v in pairs(config.jammerBaseline) do self.jammerBaseline[k] = v end
end
-- Compute and cache gain multipliers from beam angles
local bl = self.jammerBaseline
bl.omniGain = AEGIS._BeamGain(bl.omniHalfAngle) -- 0.40 at 90°
bl.wideGain = AEGIS._BeamGain(bl.wideHalfAngle) -- 0.68 at 35°
bl.dirGain = AEGIS._BeamGain(bl.dirHalfAngle) -- 2.00 at 5°
-- Convert angles to radians for runtime checks
bl.omniHalfAngleRad = math.rad(bl.omniHalfAngle)
bl.wideHalfAngleRad = math.rad(bl.wideHalfAngle)
bl.dirHalfAngleRad = math.rad(bl.dirHalfAngle)
bl.widePieHalfRad = math.rad(37) -- W70 default pie (±37°)
-- Compute WIDE preset gains and pie widths
for _, preset in ipairs(AEGIS.WIDE_PRESETS) do
preset.gain = AEGIS._BeamGain(preset.angle)
preset.angleRad = math.rad(preset.angle)
preset.pieHalfWidthRad = math.rad(preset.pieDeg)
end
-- Registries
self.ewRadars = {} -- groupName -> node
self.samSites = {} -- groupName -> node
self.pdSites = {} -- groupName -> node
self.powerSources = {} -- groupName -> node
self.commandCenters = {} -- groupName -> node
self.sectors = {} -- sectorName -> { ew, sams, pds, cmd }
self.jammers = {} -- groupName -> jammer node (EA aircraft)
self.jammerPlayers = {} -- playerName -> groupName (explicit tracking for multicrew/re-slot)
self.eaUnitMap = {} -- unitId (string) -> groupName (slot-based copilot/WSO lookup)
self.pendingNodes = {} -- groupName -> { nodeType, metadata } (late-activation groups)
-- Overrides
self.explicitSectors = {} -- groupName -> sectorName
self.siteZoneOverrides = {} -- groupName -> "WEZ" or "NEZ"
self.siteRangeOverrides = {} -- groupName -> { wez=NM, nez=NM }
self.siteActRangeOverrides = {} -- groupName -> NM
-- Event handler
self.eventHandler = nil
-- Map marker tracking
self.mapMarkerIds = {}
self:_Log("AEGIS v" .. AEGIS.Version .. " created [" .. side .. "]")
return self
end
---------------------------------------------------------------------------
-- CONFIGURATION API
---------------------------------------------------------------------------
function AEGIS:AssignToSector(groupName, sectorName)
self.explicitSectors[groupName] = sectorName
return self
end
function AEGIS:SetEngagementZone(groupName, zone)
self.siteZoneOverrides[groupName] = zone -- "WEZ" or "NEZ"
return self
end
function AEGIS:AddEWRadar(groupName, sectorName)
self:_RegisterEW(groupName, sectorName)
return self
end
function AEGIS:AddSAMSite(groupName, sectorName)
if sectorName then self.explicitSectors[groupName] = sectorName end
self:_RegisterSAM(groupName)
return self
end
function AEGIS:AddPointDefense(groupName, parentGroupName)
self:_RegisterPD(groupName, parentGroupName)
return self
end
--- Manually register a power source. targetHint follows the same convention
--- as the name suffix: e.g. "SA5-SOUTH-1" to link to SAM-SA5-SOUTH-1
function AEGIS:AddPowerSource(groupName, targetHint)
self:_RegisterPower(groupName, targetHint or "UNKNOWN")
return self
end
--- Link a power source to a specific node (SAM or EW).
--- Kill the power source -> that node goes permanently DARK.
function AEGIS:LinkPower(pwrName, targetName)
local pwr = self.powerSources[pwrName]
if not pwr then
self:_Log("LinkPower: PWR not found: " .. pwrName, true)
return self
end
-- Find target in SAMs or EWs
local target = self.samSites[targetName] or self.ewRadars[targetName]
if not target then
self:_Log("LinkPower: target not found: " .. targetName, true)
return self
end
target.powerSource = pwrName
table.insert(pwr.linkedTo, targetName)
self:_Log(" PWR LINK: " .. pwrName .. " -> " .. targetName)
return self
end
function AEGIS:AddCommandCenter(groupName, sectorName)
self:_RegisterCommand(groupName, sectorName)
return self
end
---------------------------------------------------------------------------
-- ACTIVATION
---------------------------------------------------------------------------
function AEGIS:Activate()
self:_Log("=== Activating ===")
self:_AutoDiscover()
self:_AutoAssociateSAMs()
self:_AutoAssociatePDs()
self:_AutoLinkPower()
self:_RegisterEventHandler()
if self.debug then self:_PrintTopology() end
self:_Log("=== Active [" .. self:_NodeCount() .. " nodes] ===")
-- Immediately kill emissions on all SAMs/PDs to prevent brief radar flash
-- at mission start. Full state init happens after delay.
for name, _ in pairs(self.samSites) do
local grp = Group.getByName(name)
if grp and grp:isExist() then
grp:enableEmission(false)
grp:getController():setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
end
end
for name, _ in pairs(self.pdSites) do
local grp = Group.getByName(name)
if grp and grp:isExist() then
grp:enableEmission(false)
grp:getController():setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
end
end
-- Delay full state init + EW poll to give DCS group AI time to initialize.
-- enableEmission(false) doesn't always stick if called too early,
-- so we do it once now (best effort) and again on the delayed init.
local aegis = self
timer.scheduleFunction(function()
aegis:_Log("Setting initial state (delayed)...")
aegis:_SetInitialState()
aegis:_StartEWPoll()
aegis:_StartMobilePoll()
-- Store global instance for hook script GUI bridge
AEGIS._instance = aegis
-- Start EA GUI socket listener (no-op if socket unavailable)
if aegis._StartEASocket then aegis:_StartEASocket() end
end, nil, timer.getTime() + 10)
return self
end
function AEGIS:Deactivate()
if self.eventHandler then
world.removeEventHandler(self.eventHandler)
self.eventHandler = nil
end
self:_Log("Deactivated")
end
---------------------------------------------------------------------------
-- AUTO-DISCOVERY
---------------------------------------------------------------------------
function AEGIS:_AutoDiscover()
self:_Log("Discovering groups...")
local groups = coalition.getGroups(self.coalitionId, Group.Category.GROUND)
if not groups then return end
for _, grp in ipairs(groups) do
local name = grp:getName()
-- Check if group is late-activation (visible to API but not yet spawned)
local unit1 = grp:getUnit(1)
local isActive = unit1 and unit1:isActive()
local ewSec = name:match("^EW%-([%w]+)")
if ewSec then
if isActive then
self:_RegisterEW(name, ewSec)
else
self:_RegisterPending(name, "ew", { sector = ewSec })
end
end
local samType = name:match("^SAM%-([%w]+)%-")
if samType then
if isActive then
self:_RegisterSAM(name)
else
self:_RegisterPending(name, "sam", nil)
end
end
local pdType = name:match("^PD%-([%w]+)%-")
if pdType then
if isActive then
self:_RegisterPD(name, nil)
else
self:_RegisterPending(name, "pd", nil)
end
end
-- PWR/CMD: always register (statics have no isActive concept)
local pwrRemainder = name:match("^PWR%-(.+)")
if pwrRemainder then self:_RegisterPower(name, pwrRemainder) end
local cmdSec = name:match("^CMD%-([%w]+)")
if cmdSec then self:_RegisterCommand(name, cmdSec) end
end
-- Static objects: PWR and CMD are often placed as statics (e.g., HESCO generators)
-- coalition.getGroups() only returns dynamic groups, so scan statics separately
local statics = coalition.getStaticObjects(self.coalitionId)
if statics then
for _, obj in ipairs(statics) do
local name = obj:getName()
local pwrRemainder = name:match("^PWR%-(.+)")
if pwrRemainder then self:_RegisterPower(name, pwrRemainder) end
local cmdSec = name:match("^CMD%-([%w]+)")
if cmdSec then self:_RegisterCommand(name, cmdSec) end
end
end
-- EA aircraft: scan OPPOSING coalition for EA- prefixed airplane groups
if self.eaEnabled then
local enemySide = (self.coalitionId == coalition.side.RED)
and coalition.side.BLUE or coalition.side.RED
local airGroups = coalition.getGroups(enemySide, Group.Category.AIRPLANE)
if airGroups then
for _, grp in ipairs(airGroups) do
local name = grp:getName()
local jamType = name:match("^EA%-([%w]+)%-")
if not jamType then
jamType = name:match("^ECM%-([%w]+)%-")
if jamType then
self:_Log("WARNING: " .. name .. " uses deprecated ECM- prefix, rename to EA-", true)
end
end
if jamType then
self:_RegisterJammer(name, jamType, false)
end
end
end
end
end
---------------------------------------------------------------------------
-- NODE REGISTRATION
---------------------------------------------------------------------------
function AEGIS:_RegisterEW(groupName, sectorName)
if self.ewRadars[groupName] then return end
local grp = Group.getByName(groupName)
if not grp then self:_Log("EW not found: " .. groupName, true); return end
-- Parse DET suffix: EW-SECTOR[-ID][-DET{range}]
local detOverride = nil
local tail = groupName:match("^EW%-[%w]+%-?(.*)")
if tail and tail ~= "" then
for seg in tail:gmatch("([^%-]+)") do
local detVal = seg:match("^DET(%d+)")
if detVal then detOverride = tonumber(detVal) end
end
end
local detRange = detOverride or self.ewDetectionRange
self.ewRadars[groupName] = {
name = groupName,
sector = sectorName,
state = AEGIS.STATE.DARK,
hasContacts = false,
lastContact = 0,
contacts = {}, -- cached contact positions/altitudes from last poll
powerSource = nil, -- linked PWR group name (nil = self-powered)
pos = nil, -- cached position for PB HARM detection delay
detRange = detRange,
detRangeSq = (detRange > 0) and (detRange * AEGIS.NM_TO_M) ^ 2 or 0,
}
-- Cache EW position for detection delay calculations
local unit = grp:getUnit(1)
if unit then
self.ewRadars[groupName].pos = unit:getPoint()
end
self:_EnsureSector(sectorName)
table.insert(self.sectors[sectorName].ew, groupName)
local detStr = (detRange > 0) and (" (DET " .. detRange .. " NM)") or ""
self:_Log(" EW: " .. groupName .. " -> " .. sectorName .. detStr)
end
function AEGIS:_RegisterSAM(groupName)
if self.samSites[groupName] then return end
local grp = Group.getByName(groupName)
if not grp then self:_Log("SAM not found: " .. groupName, true); return end
-- Parse name: SAM-{TYPE}-{SECTOR}[-{ID}][-{ZONE}[{NM}]][-ACT{NM}]
-- Examples: SAM-SA10-NORTH-1, SAM-SA10-NORTH-1-NEZ, SAM-SA10-NORTH-1-NEZ25
-- SAM-SA6-SOUTH-NEZ, SAM-SA2-NORTH-1-ACT30, SAM-SA10-SOUTH-2-NEZ25-ACT50
-- Suffixes are order-independent: ACT30-NEZ25 works too.
local sysType = groupName:match("^SAM%-([%w]+)%-") or "UNKNOWN"
local sysData = AEGIS.SYSTEM_DB[sysType:upper()] or AEGIS.SYSTEM_DB.UNKNOWN
-- Extract sector from name: SAM-TYPE-SECTOR[-...]
local nameSector = groupName:match("^SAM%-[%w]+%-([%w]+)")
if nameSector and not self.explicitSectors[groupName] then
self.explicitSectors[groupName] = nameSector
end
-- Scan all segments after SAM-TYPE-SECTOR for known prefixes
local zoneOverride, rangeOverride, actOverride = nil, nil, nil
local isMobile = false
local tail = groupName:match("^SAM%-[%w]+%-[%w]+%-?(.*)")
if tail and tail ~= "" then
for seg in tail:gmatch("([^%-]+)") do
local zone, range = seg:match("^(WEZ)(%d*)")
if not zone then zone, range = seg:match("^(NEZ)(%d*)") end
if zone then
zoneOverride = zone
if range and range ~= "" then rangeOverride = tonumber(range) end
elseif seg == "MOB" then
isMobile = true
else
local actVal = seg:match("^ACT(%d+)")
if actVal then actOverride = tonumber(actVal) end
end
end
end
-- Apply overrides discovered from name
if zoneOverride then
self.siteZoneOverrides[groupName] = zoneOverride
if rangeOverride then
self.siteRangeOverrides[groupName] = self.siteRangeOverrides[groupName] or {}
if zoneOverride == "WEZ" then
self.siteRangeOverrides[groupName].wez = rangeOverride
elseif zoneOverride == "NEZ" then
self.siteRangeOverrides[groupName].nez = rangeOverride
end
end
end
if actOverride then
self.siteActRangeOverrides[groupName] = actOverride
end
self.samSites[groupName] = {
name = groupName,
sector = nil,
sysType = sysType,
sysData = sysData,
state = AEGIS.STATE.DARK,
emconGen = 0, -- generation counter for EMCON timer cancellation
lastContactTime = 0, -- for EMCON re-engage timeout
pos = nil, -- cached position from init
-- EMCON jitter tracking
sweepsSinceDetect = 0, -- consecutive empty sweeps (relaxes timing)
lastSweepHadContact = false, -- did last sweep see anything? (tightens timing)
spooked = false, -- neighbor was killed, extend next silent phase
spookedUntil = 0, -- timer.getTime() when spook wears off
powerSource = nil, -- linked PWR group name (nil = self-powered)
harmCooldownUntil = 0, -- timer.getTime() when HARM dodge cooldown expires
-- HARM reaction tracking (Phase 3.2)
harmEvents = {}, -- list of timestamps for multi-HARM saturation tracking
harmMultiThreshold = math.random(self.harmMultiThresholdMin, self.harmMultiThresholdMax), -- crew personality
harmReaction = nil, -- current reaction: "STAY_HOT" | "LAST_DITCH" | "GO_DARK" | nil
harmReactionGen = 0, -- generation counter for pending reaction timers
harmReactionPending = false, -- true while crew is processing first HARM (blocks timer restart)
harmWeapon = nil, -- DCS weapon object ref for in-flight check
harmReactionStart = 0, -- timer.getTime() when reaction began (for hard cap)
-- PB HARM inbound flag (own-radar detection)
harmInbound = 0, -- timer.getTime() when set (0 = none)
harmInboundExpiry = 0, -- timer.getTime() when flag expires
-- Alert frustration (Phase 3.3)
alertWithoutWezSince = 0, -- timer.getTime() when ALERT first had no WEZ contact (0 = has contact)
alertFrustrationTimeout = 0, -- randomized timeout for this frustration cycle
frustrationCooldownUntil = 0, -- timer.getTime() when frustration cooldown expires (WEZ contact overrides)
-- EA jammer tracking
jammed = false, -- true when an active jammer is suppressing this SAM
jammedEmconGen = 0, -- generation counter for jammed EMCON timers
jammedEmconActive = false, -- true when in jammed EMCON cycling
-- Home-on-Jam (Phase 6.3)
hojUntil = 0, -- timer.getTime() when HOJ immunity window expires (0 = inactive)
hojCooldownUntil = 0, -- timer.getTime() when HOJ re-roll eligible (0 = ready)
hojPeekCount = 0, -- consecutive peeks with jammer in range (escalates probability)
-- Critical unit tracking (mission kill detection)
trackRadarUnit = nil, -- DCS unit name of critical tracking radar (nil = group-kill only)
commandPostUnit = nil, -- DCS unit name of critical command post (nil = no CP tracking)
-- Mobile SAM position polling (MOB suffix)
mobile = isMobile, -- true if MOB suffix present
lastPosUpdate = 0, -- timer.getTime() of last position refresh
}
-- Cache position
local unit = grp:getUnit(1)
if unit then
self.samSites[groupName].pos = unit:getPoint()
end
-- Critical unit tracking: find the tracking radar by DCS type name
if sysData.trackRadar then
local units = grp:getUnits()
if units then
for _, u in ipairs(units) do
if u:getTypeName() == sysData.trackRadar then
self.samSites[groupName].trackRadarUnit = u:getName()
break
end
end
if not self.samSites[groupName].trackRadarUnit then
self:_Log(" WARNING: " .. groupName .. " has no " .. sysData.trackRadar
.. " unit (critical unit tracking disabled)", true)
end
end
end
-- Critical unit tracking: find the command post by DCS type name
if sysData.commandPost then
local units = grp:getUnits()
if units then
for _, u in ipairs(units) do
if u:getTypeName() == sysData.commandPost then
self.samSites[groupName].commandPostUnit = u:getName()
break
end
end
if not self.samSites[groupName].commandPostUnit then
self:_Log(" WARNING: " .. groupName .. " has no " .. sysData.commandPost
.. " unit (CP tracking disabled)", true)
end
end
end
local logMsg = " SAM: " .. groupName .. " [" .. sysType .. " " .. sysData.cat .. "]"
if zoneOverride then
logMsg = logMsg .. " " .. zoneOverride
if rangeOverride then logMsg = logMsg .. " " .. rangeOverride .. "NM" end
end
if actOverride then
logMsg = logMsg .. " ACT" .. actOverride .. "NM"
end
if isMobile then
logMsg = logMsg .. " [MOB]"
end
if self.samSites[groupName].trackRadarUnit then
logMsg = logMsg .. " (TR: " .. self.samSites[groupName].trackRadarUnit .. ")"
end
if self.samSites[groupName].commandPostUnit then
logMsg = logMsg .. " (CP: " .. self.samSites[groupName].commandPostUnit .. ")"
end
if self.samSites[groupName].pos then
local p = self.samSites[groupName].pos
logMsg = logMsg .. string.format(" @(%.0f, %.0f)", p.x, p.z)
end
self:_Log(logMsg)
end
function AEGIS:_RegisterPD(groupName, parentName)
if self.pdSites[groupName] then return end
local grp = Group.getByName(groupName)
if not grp then self:_Log("PD not found: " .. groupName, true); return end
local sysType = groupName:match("^PD%-([%w]+)%-") or "UNKNOWN"
local sysData = AEGIS.SYSTEM_DB[sysType:upper()] or AEGIS.SYSTEM_DB.UNKNOWN
self.pdSites[groupName] = {
name = groupName,
sysType = sysType,
sysData = sysData,
parent = parentName, -- nil = auto-associate at init
state = AEGIS.STATE.DARK,
pos = nil,
}
local unit = grp:getUnit(1)
if unit then
self.pdSites[groupName].pos = unit:getPoint()
end
self:_Log(" PD: " .. groupName .. " [" .. sysType .. "]"
.. (parentName and (" -> " .. parentName) or " (parent pending)"))
end
function AEGIS:_RegisterPower(groupName, targetHint)
if self.powerSources[groupName] then return end
-- Cache position for display
local pos = nil
local grp = Group.getByName(groupName)
if grp and grp:isExist() then
local u = grp:getUnit(1); if u then pos = u:getPoint() end
else
local s = StaticObject.getByName(groupName)
if s and s:isExist() then pos = s:getPoint() end
end
self.powerSources[groupName] = {
name = groupName, targetHint = targetHint, alive = true,
pos = pos, linkedTo = {},
}
self:_Log(" PWR: " .. groupName .. " (target hint: " .. targetHint .. ")")
end
function AEGIS:_RegisterCommand(groupName, sectorName)
if self.commandCenters[groupName] then return end
self.commandCenters[groupName] = {
name = groupName, sector = sectorName, alive = true,
}
self:_EnsureSector(sectorName)
table.insert(self.sectors[sectorName].cmd, groupName)
self:_Log(" CMD: " .. groupName .. " -> " .. sectorName)
end
--- Register a late-activation group for deferred activation via S_EVENT_BIRTH.
--- Pre-computes name-derived metadata (type, sector, zone/ACT/DET overrides) so
--- activation can proceed instantly without re-parsing.
function AEGIS:_RegisterPending(groupName, nodeType, meta)
if self.pendingNodes[groupName] then return end
local pending = { nodeType = nodeType, activated = false }
if nodeType == "sam" then
-- Pre-parse SAM name: type, sector, zone/range/ACT overrides
local sysType = groupName:match("^SAM%-([%w]+)%-") or "UNKNOWN"
local nameSector = groupName:match("^SAM%-[%w]+%-([%w]+)")
pending.sysType = sysType
pending.sector = nameSector
-- Scan tail segments for zone/ACT/MOB overrides (mirrors _RegisterSAM parsing)
local tail = groupName:match("^SAM%-[%w]+%-[%w]+%-?(.*)")
if tail and tail ~= "" then
for seg in tail:gmatch("([^%-]+)") do
local zone, range = seg:match("^(WEZ)(%d*)")
if not zone then zone, range = seg:match("^(NEZ)(%d*)") end
if zone then
pending.zoneOverride = zone
if range and range ~= "" then pending.rangeOverride = tonumber(range) end
elseif seg == "MOB" then
pending.mobile = true
else
local actVal = seg:match("^ACT(%d+)")
if actVal then pending.actOverride = tonumber(actVal) end
end
end
end
elseif nodeType == "ew" then
pending.sector = meta and meta.sector
-- Pre-parse DET suffix
local tail = groupName:match("^EW%-[%w]+%-?(.*)")
if tail and tail ~= "" then
for seg in tail:gmatch("([^%-]+)") do
local detVal = seg:match("^DET(%d+)")
if detVal then pending.detOverride = tonumber(detVal) end
end
end
elseif nodeType == "pd" then
-- PD has no pre-parsed metadata beyond type
pending.sysType = groupName:match("^PD%-([%w]+)%-") or "UNKNOWN"
end
self.pendingNodes[groupName] = pending
local pendLog = " PENDING: " .. groupName .. " [" .. nodeType .. "] (late activation)"
if pending.mobile then pendLog = pendLog .. " [MOB]" end
self:_Log(pendLog)
end
function AEGIS:_RegisterJammer(groupName, jamType, playerControlled)
if self.jammers[groupName] then return end
local grp = Group.getByName(groupName)
if not grp then self:_Log("Jammer not found: " .. groupName, true); return end
local dbKey = jamType:upper()
local dbEntry = AEGIS.JAMMER_DB[dbKey] or AEGIS.JAMMER_DB.UNKNOWN
local mult = dbEntry.mult or 0.5
local pos = nil
local heading = 0
local unit = grp:getUnit(1)
if unit then
local p3 = unit:getPosition()
if p3 then
pos = p3.p
heading = math.atan2(p3.x.z, p3.x.x) -- forward vector → heading (radians)
else
pos = unit:getPoint()
end
end
self.jammers[groupName] = {
name = groupName,
jamType = dbKey,
mult = mult,
pos = pos,
heading = heading,
alive = true,
active = not playerControlled, -- AI starts on, players start off (F10 toggle)
playerControlled = playerControlled,
-- Pod management
mode = playerControlled and "OFF" or "OMNI", -- OMNI | WIDE | DIR2 | OFF
wideGain = nil, -- per-jammer WIDE gain (nil = use baseline)
wideHalfAngleRad = nil, -- per-jammer WIDE cone (nil = use baseline)
widePreset = "W70", -- current WIDE preset label
bearingLocked = false, -- true = omni spray locked to fixed bearing
lockedBearing = 0, -- radians: fixed bearing for locked omni spray
magDeclination = nil, -- degrees: true-minus-mag offset (nil = uncalibrated)
brgInputMode = "REL", -- F10 bearing-entry mode: "REL" or "ABS" (UI state only)
pod1Target = nil, -- group name of directional pod 1 target (nil = unassigned)
pod2Target = nil, -- group name of directional pod 2 target (nil = unassigned)
-- Player interaction
groupId = grp:getID(),
knownEmitters = {}, -- { groupName = {distSq, isEW, sysType} } — for ESM display
menuRoot = nil, -- F10 menu root handle
menuRefreshScheduled = false, -- prevents duplicate 30s refresh timers
statusActive = false, -- persistent status display toggle
}
-- Map unit IDs to group name for slot-based copilot/WSO lookup
local units = grp:getUnits()
if units then
for _, u in ipairs(units) do
local uid = tostring(u:getID())
self.eaUnitMap[uid] = groupName
end
end
self:_Log(" EA: " .. groupName .. " [" .. dbKey .. " x" .. mult .. "] "
.. (playerControlled and "PLAYER (off until F10)" or "AI OMNI")
.. " range=" .. self.jammerBaseline.effectRange .. "NM")
end
function AEGIS:_EnsureSector(name)
if not self.sectors[name] then
self.sectors[name] = { ew={}, sams={}, pds={}, cmd={}, jammed=false, jamBearing=0 }
end
end
---------------------------------------------------------------------------
-- AUTO-ASSOCIATION
---------------------------------------------------------------------------
function AEGIS:_AutoAssociateSAMs()
self:_Log("Associating SAMs to EW...")
local ewPos = {}
for ewName, n in pairs(self.ewRadars) do
local grp = Group.getByName(ewName)
if grp and grp:isExist() then
local u = grp:getUnit(1)
if u then ewPos[ewName] = { pos=u:getPoint(), sector=n.sector } end
end
end
local threshold = self.autoAssocRange * AEGIS.NM_TO_M
for samName, n in pairs(self.samSites) do
local explicit = self.explicitSectors[samName]
if explicit then
n.sector = explicit
self:_EnsureSector(explicit)
table.insert(self.sectors[explicit].sams, samName)
self:_Log(" " .. samName .. " -> " .. explicit .. " (explicit)")
elseif n.pos then
local best, bestDist = nil, math.huge
for _, ew in pairs(ewPos) do
local d = self:_Dist(n.pos, ew.pos)
if d < bestDist then bestDist = d; best = ew end
end
if best and bestDist <= threshold then
n.sector = best.sector
self:_EnsureSector(best.sector)
table.insert(self.sectors[best.sector].sams, samName)
self:_Log(" " .. samName .. " -> " .. best.sector
.. " (" .. math.floor(bestDist/AEGIS.NM_TO_M) .. " NM)")
else
n.sector = "_AUTO"
self:_EnsureSector("_AUTO")
table.insert(self.sectors["_AUTO"].sams, samName)
self:_Log(" " .. samName .. " -> AUTONOMOUS")
end
end
end
end
function AEGIS:_AutoAssociatePDs()
self:_Log("Associating PD to parents...")
local threshold = self.pdAssocRange * AEGIS.NM_TO_M
for pdName, pd in pairs(self.pdSites) do
if pd.parent then
self:_Log(" " .. pdName .. " -> " .. pd.parent .. " (explicit)")
-- Add to parent's sector
local parentNode = self.samSites[pd.parent]
if parentNode and parentNode.sector then
pd.sector = parentNode.sector
self:_EnsureSector(parentNode.sector)
table.insert(self.sectors[parentNode.sector].pds, pdName)
end
elseif pd.pos then
-- Find nearest AREA SAM
local best, bestDist, bestSector = nil, math.huge, nil
for samName, sam in pairs(self.samSites) do
if sam.sysData.cat == "AREA" and sam.pos then
local d = self:_Dist(pd.pos, sam.pos)
if d < bestDist then
bestDist = d; best = samName; bestSector = sam.sector
end
end
end
-- Also check EW radars as potential parents
for ewName, ew in pairs(self.ewRadars) do
local grp = Group.getByName(ewName)
if grp and grp:isExist() then
local u = grp:getUnit(1)
if u then
local d = self:_Dist(pd.pos, u:getPoint())
if d < bestDist then
bestDist = d; best = ewName; bestSector = ew.sector
end
end
end
end
if best and bestDist <= threshold then
pd.parent = best
if bestSector then
pd.sector = bestSector
self:_EnsureSector(bestSector)
table.insert(self.sectors[bestSector].pds, pdName)
end
self:_Log(" " .. pdName .. " -> " .. best
.. " (" .. math.floor(bestDist/AEGIS.NM_TO_M*10)/10 .. " NM)")
else
self:_Log(" " .. pdName .. " -> NO PARENT (too far)", true)
end
end
end
-- Build reverse map: SAM/EW → list of child PD names
for pdName, pd in pairs(self.pdSites) do
if pd.parent then
local parentSam = self.samSites[pd.parent]
if parentSam then
if not parentSam.pds then parentSam.pds = {} end
table.insert(parentSam.pds, pdName)
end
-- EW parents don't need reverse map (PD slaving uses samSites only)
end
end
end
---------------------------------------------------------------------------
-- AUTO-LINK POWER
---------------------------------------------------------------------------
--- Auto-link PWR groups to target nodes by naming convention.
--- PWR-SA5-SOUTH-1 -> links to SAM-SA5-SOUTH-1
--- PWR-EW-NORTH -> links to EW-NORTH
--- Also supports manual LinkPower() calls made before Activate().
function AEGIS:_AutoLinkPower()
self:_Log("Linking power sources...")
for pwrName, pwr in pairs(self.powerSources) do
-- Skip if already manually linked
if #pwr.linkedTo > 0 then
for _, t in ipairs(pwr.linkedTo) do
self:_Log(" " .. pwrName .. " -> " .. t .. " (explicit)")
end
else
local hint = pwr.targetHint
local linked = false
-- Try SAM-{hint} first (most common: PWR-SA5-SOUTH-1 -> SAM-SA5-SOUTH-1)
local samTarget = "SAM-" .. hint
if self.samSites[samTarget] then
self.samSites[samTarget].powerSource = pwrName
table.insert(pwr.linkedTo, samTarget)
self:_Log(" " .. pwrName .. " -> " .. samTarget .. " (by name)")
linked = true
end
-- Try hint directly (for EW: PWR-EW-NORTH -> EW-NORTH)
if not linked and self.ewRadars[hint] then
self.ewRadars[hint].powerSource = pwrName
table.insert(pwr.linkedTo, hint)
self:_Log(" " .. pwrName .. " -> " .. hint .. " (by name)")
linked = true
end
if not linked then
self:_Log(" WARNING: " .. pwrName .. " -> no matching node found for '" .. hint .. "'", true)
end
end
end
-- Warn about needsPower nodes with no power source
for samName, sam in pairs(self.samSites) do
if sam.sysData.needsPower and not sam.powerSource then
self:_Log(" WARNING: " .. samName .. " needs power but has no PWR linked!", true)
end
end
end
---------------------------------------------------------------------------
-- INITIAL STATE
---------------------------------------------------------------------------
function AEGIS:_SetInitialState()
for name, sam in pairs(self.samSites) do
self:_ApplyState(name, "sam", AEGIS.STATE.DARK)
-- SAMs with no network support start EMCON cycling immediately
local sector = sam.sector
if sector == "_AUTO" then
self:_Log(name .. ": autonomous, starting EMCON")
self:_StartEMCON(name)
elseif sector then
local hasEW = self:_SectorHasEW(sector)
local hasC2 = self:_SectorHasC2(sector)
if not hasEW or not hasC2 then
self:_Log(name .. ": sector " .. sector .. " missing EW/C2, starting EMCON")
self:_StartEMCON(name)
end
end
end
for name, _ in pairs(self.pdSites) do
self:_ApplyState(name, "pd", AEGIS.STATE.DARK)
end
end
---------------------------------------------------------------------------
-- STATE APPLICATION (single point for DCS commands)
---------------------------------------------------------------------------
function AEGIS:_ApplyState(groupName, nodeType, newState)
local node
if nodeType == "sam" then node = self.samSites[groupName]
elseif nodeType == "pd" then node = self.pdSites[groupName]
else return end
if not node then return end
if node.state == newState then return end
if node.state == AEGIS.STATE.DESTROYED then return end
local old = node.state
node.state = newState
-- State changed but DCS commands identical — update internal state only
-- DARK, AWARE, EMCON_ON all emit false + WEAPON_HOLD
-- ALERT, EMCON_ENGAGED both emit true + WEAPON_FREE
-- EMCON_OFF emits true + WEAPON_HOLD (unique — always needs API calls)
local STATE = AEGIS.STATE
local oldEmit = (old == STATE.ALERT or old == STATE.EMCON_OFF or old == STATE.EMCON_ENGAGED)
local newEmit = (newState == STATE.ALERT or newState == STATE.EMCON_OFF or newState == STATE.EMCON_ENGAGED)
local oldFree = (old == STATE.ALERT or old == STATE.EMCON_ENGAGED)
local newFree = (newState == STATE.ALERT or newState == STATE.EMCON_ENGAGED)
if oldEmit == newEmit and oldFree == newFree then
self:_Log(groupName .. ": " .. (old or "NEW") .. " -> " .. newState .. " (internal)")
return
end
local grp = Group.getByName(groupName)
if not grp or not grp:isExist() then
node.state = AEGIS.STATE.DESTROYED
return
end
local ctrl = grp:getController()
-- ALARM_STATE stays RED at all times. This keeps DCS AI "combat ready"
-- internally so there's no radar warm-up delay when emissions come on.
-- We control behavior solely via enableEmission() and ROE.
ctrl:setOption(AI.Option.Ground.id.ALARM_STATE, AEGIS.ALARM.RED)
if newState == AEGIS.STATE.DARK or newState == AEGIS.STATE.AWARE then
grp:enableEmission(false)
ctrl:setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
elseif newState == AEGIS.STATE.ALERT then
grp:enableEmission(true)
ctrl:setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_FREE)
elseif newState == AEGIS.STATE.EMCON_ON then
grp:enableEmission(false)
ctrl:setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
elseif newState == AEGIS.STATE.EMCON_OFF then
grp:enableEmission(true)
ctrl:setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
elseif newState == AEGIS.STATE.EMCON_ENGAGED then
grp:enableEmission(true)
ctrl:setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_FREE)
end
self:_Log(groupName .. ": " .. (old or "NEW") .. " -> " .. newState)
-- PD slaving: when a parent SAM transitions, update its PDs immediately
if nodeType == "sam" and node.pds then
local now = timer.getTime()
for _, pdName in ipairs(node.pds) do
local pd = self.pdSites[pdName]
if pd and pd.state ~= AEGIS.STATE.DESTROYED then
-- If parent is in active HARM cooldown, PD stays ALERT for defense
if node.harmCooldownUntil and now < node.harmCooldownUntil then
if pd.state ~= AEGIS.STATE.ALERT then
self:_ApplyState(pdName, "pd", AEGIS.STATE.ALERT)
end
elseif newState == AEGIS.STATE.ALERT
or newState == AEGIS.STATE.EMCON_ENGAGED then
self:_ApplyState(pdName, "pd", AEGIS.STATE.ALERT)
else
self:_ApplyState(pdName, "pd", AEGIS.STATE.DARK)
end
end
end
end
end
---------------------------------------------------------------------------
-- MOBILE SAM POSITION POLLING
---------------------------------------------------------------------------
--- Rebuild the ordered list of mobile SAM group names.
function AEGIS:_RebuildMobileList()
self.mobileSams = {}
for name, sam in pairs(self.samSites) do
if sam.mobile and sam.state ~= AEGIS.STATE.DESTROYED then
table.insert(self.mobileSams, name)
end
end
table.sort(self.mobileSams)
if (self.mobPollIndex or 0) > #self.mobileSams then
self.mobPollIndex = 0
end
end
--- Return the appropriate poll interval for a mobile SAM based on state.
function AEGIS:_GetMobInterval(sam)
if sam.state == AEGIS.STATE.DARK then
local sector = self.sectors[sam.sector]
if sector and sector._contactCount and sector._contactCount > 0 then
return AEGIS.MOB_POLL_ACTIVE
end
return AEGIS.MOB_POLL_IDLE
end
return AEGIS.MOB_POLL_ACTIVE
end
--- Start the mobile SAM position poll timer. Called from Activate().
function AEGIS:_StartMobilePoll()
self:_RebuildMobileList()
if #self.mobileSams == 0 then
self:_Log("Mobile poll: no mobile SAMs, skipping")
return
end
self.mobPollIndex = 0
local numMobile = #self.mobileSams
local subInterval = AEGIS.MOB_POLL_IDLE / math.max(numMobile, 1)
if subInterval < 1.0 then subInterval = 1.0 end
self.mobSubInterval = subInterval
local aegis = self
local function poll()
local ok, err = pcall(aegis._PollNextMobile, aegis)
if not ok then
aegis:_Log("Mobile poll error (recovering): " .. tostring(err), true)
end
return timer.getTime() + aegis.mobSubInterval
end
timer.scheduleFunction(poll, nil, timer.getTime() + 15)
self:_Log("Mobile poll started (" .. numMobile .. " mobile SAMs, sub-interval "
.. string.format("%.1f", self.mobSubInterval) .. "s)")
end
--- Round-robin poll: refresh position of one mobile SAM per tick.
function AEGIS:_PollNextMobile()
local numMobile = #self.mobileSams
if numMobile == 0 then return end
self.mobPollIndex = self.mobPollIndex + 1
if self.mobPollIndex > numMobile then
self.mobPollIndex = 1
end
local samName = self.mobileSams[self.mobPollIndex]
local sam = self.samSites[samName]
if not sam or sam.state == AEGIS.STATE.DESTROYED then return end
-- Tiered cadence: skip if not due yet
local now = timer.getTime()
local elapsed = now - sam.lastPosUpdate
local interval = self:_GetMobInterval(sam)
if elapsed < interval then return end
-- Refresh position
local grp = Group.getByName(samName)
if not grp or not grp:isExist() then return end
local unit = grp:getUnit(1)
if not unit then return end
local newPos = unit:getPoint()
local oldPos = sam.pos
sam.pos = newPos
sam.lastPosUpdate = now
-- Debug log if meaningfully moved
if self.debug and oldPos then
local dx = newPos.x - oldPos.x
local dz = newPos.z - oldPos.z
local distSq = dx * dx + dz * dz
if distSq > (AEGIS.MOB_MOVE_THRESHOLD * AEGIS.MOB_MOVE_THRESHOLD) then
self:_Log("MOB " .. samName .. " moved "
.. string.format("%.0f", math.sqrt(distSq)) .. "m → @("
.. string.format("%.0f", newPos.x) .. ", "
.. string.format("%.0f", newPos.z) .. ")")
end
end
end
---------------------------------------------------------------------------
-- EW POLLING + WEZ GATING (Phase 1 core loop)
---------------------------------------------------------------------------
function AEGIS:_StartEWPoll()
-- Build ordered sector list (exclude _AUTO which has no EWs)
self.sectorPollOrder = {}
for name, sector in pairs(self.sectors) do
if name ~= "_AUTO" and #sector.ew > 0 then
table.insert(self.sectorPollOrder, name)
end
end
table.sort(self.sectorPollOrder) -- deterministic order
self.sectorPollIndex = 0
self.jammerPollCounter = 0
local numSectors = math.max(#self.sectorPollOrder, 1)
self.pollSubInterval = self.ewPollInterval / numSectors
local aegis = self
local function poll()
local ok, err = pcall(aegis._PollNextSector, aegis)
if not ok then
aegis:_Log("EW poll error (recovering): " .. tostring(err), true)
end
return timer.getTime() + aegis.pollSubInterval
end
timer.scheduleFunction(poll, nil, timer.getTime() + 5)
self:_Log("EW poll started (every " .. self.ewPollInterval .. "s, "
.. #self.sectorPollOrder .. " sectors, sub-interval "
.. string.format("%.1f", self.pollSubInterval) .. "s, EW sub-poll "
.. (AEGIS.EW_SUB_INTERVAL * 1000) .. "ms)")
end
function AEGIS:_PollNextSector()
local numSectors = #self.sectorPollOrder
if numSectors == 0 then return end
-- Advance round-robin index (1-based, wraps)
self.sectorPollIndex = self.sectorPollIndex + 1
if self.sectorPollIndex > numSectors then
self.sectorPollIndex = 1
end
local sectorName = self.sectorPollOrder[self.sectorPollIndex]
local sector = self.sectors[sectorName]
if not sector then return end
-- Jammer position refresh: once per full rotation (DCS API call)
-- Emitter scan: every sub-cycle for near-real-time WSO alerts
self.jammerPollCounter = self.jammerPollCounter + 1
local fullRotation = (self.jammerPollCounter >= numSectors)
if fullRotation then
self.jammerPollCounter = 0
if self.eaEnabled then
self:_UpdateJammerPositions()
end
end
if self.eaEnabled then
self:_ScanJammerEmitters()
end
-- Clear accumulated state for this sector's EW sub-poll sequence
sector._contacts = {}
sector._contactCount = 0
sector._jammed = false
sector._jamBearing = 0
sector._jamPies = {}
sector._hasEW = false
sector._fullRotation = fullRotation
-- Start EW sub-polling: first EW fires now, rest at 200ms intervals
self:_PollSectorEW(sectorName, 1)
end
--- Poll a single EW radar within a sector and accumulate contacts.
--- On the last EW, runs the SAM state machine loop.
function AEGIS:_PollSectorEW(sectorName, ewIndex)
local sector = self.sectors[sectorName]
if not sector then return end
local now = timer.getTime()
local numEWs = #sector.ew
-- Poll this one EW
if ewIndex <= numEWs then
local ewName = sector.ew[ewIndex]
local ew = self.ewRadars[ewName]
if ew and ew.state ~= AEGIS.STATE.DESTROYED then
local grp = Group.getByName(ewName)
if not grp or not grp:isExist() or grp:getSize() == 0 then
ew.state = AEGIS.STATE.DESTROYED
self:_Log(ewName .. ": EW destroyed/despawned")
elseif self:_NodeHasPower(ew) then
sector._hasEW = true -- at least one live, powered EW (cached for SAM loop)
-- EA contact filtering: compute jam effects on this EW before processing contacts
local ewJamEffects = {}
local ewJamBearing = 0
if self.eaEnabled then
ewJamEffects, ewJamBearing = self:_GetEWJamState(ew)
if #ewJamEffects > 0 then
if self.debug then
local brgDeg = math.floor(math.deg(ewJamBearing) + 0.5) % 360
self:_Log(ewName .. ": EW jam effects (" .. #ewJamEffects
.. " effects, BRG " .. brgDeg .. ")")
end
-- Accumulate pie geometry for on-axis SAM check
-- (sectorJammed set later, only when a contact is actually masked)
if ew.pos then
for _, eff in ipairs(ewJamEffects) do
table.insert(sector._jamPies, {
ewX = ew.pos.x,
ewZ = ew.pos.z,
jdx = math.cos(eff.bearingToJammer),
jdz = math.sin(eff.bearingToJammer),
cosHalf = math.cos(eff.pieHalfWidth),
})
end
end
sector._jamBearing = ewJamBearing
end
end
local ctrl = grp:getController()
if not ctrl then
ew.state = AEGIS.STATE.DESTROYED
self:_Log(ewName .. ": EW has no controller (despawned?)", true)
else
local detected = ctrl:getDetectedTargets(Controller.Detection.RADAR)
if detected and #detected > 0 then
local addedAny = false
for _, det in ipairs(detected) do
if det.object and det.object:isExist() then
local validContact = true
-- Filter out weapons in flight (bombs, missiles) but allow decoys
local catOk, objCat = pcall(det.object.getCategory, det.object)
if catOk and objCat == 2 then -- Weapon object
-- Only allow decoys through (TALD: missileCategory=6, guidance=1)
validContact = false
local descOk, desc = pcall(det.object.getDesc, det.object)
if descOk and desc and desc.missileCategory == 6 and desc.guidance == 1 then
validContact = true -- Decoy, let it fool the radar
end
end
if validContact then
local pos = det.object:getPoint()
if pos then
-- EW detection range cap (0 = unlimited)
if ew.detRangeSq > 0 and ew.pos then
local dx = ew.pos.x - pos.x
local dz = ew.pos.z - pos.z
if (dx*dx + dz*dz) > ew.detRangeSq then
pos = nil
end
end
-- EA jam zone filter: contact inside jam pie + beyond burn-through = masked
if pos and #ewJamEffects > 0 then
if self:_IsContactJammed(ew, pos, ewJamEffects) then
if self.debug then
self:_Log(ewName .. ": contact MASKED by EA (jam zone filter)")
end
pos = nil
sector._jammed = true -- observable: jamming actually ate a contact
end
end
if pos then
table.insert(sector._contacts, {
pos = pos,
alt = pos.y / AEGIS.FT_TO_M,
})
sector._contactCount = sector._contactCount + 1
addedAny = true
end
end
end
end
end
-- Only flag contacts after filtering (DCS sees beyond our cap)
if addedAny then
if not ew.hasContacts then
self:_Log(ewName .. ": contacts acquired (" .. ew.sector .. ")")
end
ew.hasContacts = true
ew.lastContact = now
elseif ew.hasContacts and (now - ew.lastContact) > self.alertTimeout then
self:_Log(ewName .. ": contacts lost (" .. ew.sector .. ")")
ew.hasContacts = false
end
else
if ew.hasContacts and (now - ew.lastContact) > self.alertTimeout then
self:_Log(ewName .. ": contacts lost (" .. ew.sector .. ")")
ew.hasContacts = false
end
end
end -- ctrl check
end
end
end
-- If more EWs remain, schedule next sub-tick
if ewIndex < numEWs then
local aegis = self
local sn = sectorName
local nextIdx = ewIndex + 1
timer.scheduleFunction(function()
local ok, err = pcall(aegis._PollSectorEW, aegis, sn, nextIdx)
if not ok then
aegis:_Log("EW sub-poll error (recovering): " .. tostring(err), true)
end
return nil
end, nil, now + AEGIS.EW_SUB_INTERVAL)
return
end
-- Last EW (or only EW): run SAM loop with accumulated contacts
self:_ProcessSectorSAMs(sectorName)
end
--- Process SAM state machine for a sector using accumulated EW contacts.
--- Called once per sector poll, after all EWs have been sub-polled.
function AEGIS:_ProcessSectorSAMs(sectorName)
local sector = self.sectors[sectorName]
if not sector then return end
local now = timer.getTime()
local sectorJammed = sector._jammed
local sectorJamPies = sector._jamPies
local contactCount = sector._contactCount
-- Sector jam awareness (C2 path): EW detected jamming → warn sector SAMs
if self.eaEnabled then
sector.jammed = sectorJammed
sector.jamBearing = sector._jamBearing
end
-- WEZ checks for this sector's SAMs
local hasC2 = self:_SectorHasC2(sectorName)
local hasEW = sector._hasEW -- cached from EW sub-poll phase (avoids redundant API calls)
local sectorContacts = (contactCount > 0) and sector._contacts or nil
for _, samName in ipairs(sector.sams) do
local sam = self.samSites[samName]
if sam and sam.state ~= AEGIS.STATE.DESTROYED then
-- Per-node power check: no power = permanently dark
if not self:_NodeHasPower(sam) then
if sam.state ~= AEGIS.STATE.DARK then
self:_StopEMCON(samName)
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
end
else
-- Has power: normal operations
-- HARM cooldown: SAM is hiding from a HARM, do not change state
if sam.harmCooldownUntil > now then
-- Skip all state logic while dodging HARM
-- Jammed EMCON: jammer controls this SAM's cycling, poll hands off
elseif sam.jammedEmconActive then
-- Skip state logic — jammed EMCON timer chain owns state transitions
else
-- If in EMCON states, EMCON system handles this SAM
local inEmcon = (sam.state == AEGIS.STATE.EMCON_ON
or sam.state == AEGIS.STATE.EMCON_OFF
or sam.state == AEGIS.STATE.EMCON_ENGAGED)
if not inEmcon then
if not hasEW or not hasC2 then
-- Lost EW or C2 (or both): enter EMCON
self:_StartEMCON(samName)
elseif self.eaEnabled and sectorJammed and not sectorContacts
and (sam.state == AEGIS.STATE.DARK or sam.state == AEGIS.STATE.AWARE) then
-- Sector fully jammed, zero contacts: EW network blinded, C2 warns all SAMs
-- On-axis SAMs (within jam pie cone) get aggressive jammed EMCON — they're in the threat axis
-- Off-axis SAMs get normal EMCON — network degraded but no immediate threat
local actNM = self:_GetActRange(sam)
if self:_JammerOnAxis(sam, actNM, sectorJamPies) then
self:_Log(samName .. ": sector JAMMED, threat axis (jammed EMCON)")
self:_StartJammedEMCON(samName)
end
-- off-axis: EW has clean coverage in SAM's direction, stay on network
else
-- Full network: check activation range
if sectorContacts then
local inRange, nearestDist, actRange = self:_CheckActivation(sam, sectorContacts)
local inWEZ = self:_CheckWEZ(sam, sectorContacts)
-- For ALERT SAMs: check full WEZ (ambush sprung, use full capability)
-- Non-NEZ SAMs: _CheckFullWEZ == _CheckWEZ, zero behavior change
local inFullWEZ = false
if sam.state == AEGIS.STATE.ALERT then
inFullWEZ = self:_CheckFullWEZ(sam, sectorContacts)
end
-- Frustration uses full WEZ for ALERT SAMs, zone-aware WEZ otherwise
local wezForFrustration = (sam.state == AEGIS.STATE.ALERT) and inFullWEZ or inWEZ
if inRange or (sam.state == AEGIS.STATE.ALERT and inFullWEZ) then
-- Either in actRange (wake up), or ALERT with full-WEZ contacts (stay hot)
-- Frustration cooldown: crew just powered down, won't re-alert
-- unless a real threat enters the WEZ
if sam.frustrationCooldownUntil > now and not wezForFrustration then
sam.lastContactTime = now
-- Stay AWARE — crew is ignoring the orbiting contact
else
-- WEZ contact breaks frustration cooldown
if wezForFrustration and sam.frustrationCooldownUntil > now then
self:_Log(samName .. ": WEZ contact — breaking frustration cooldown")
sam.frustrationCooldownUntil = 0
end
sam.lastContactTime = now
self:_ApplyState(samName, "sam", AEGIS.STATE.ALERT)
-- Own-radar HARM detection: SAM just went hot, radar paints the HARM
self:_TriggerHarmInboundReaction(samName)
-- Alert frustration: ALERT but nothing in the WEZ?
if wezForFrustration then
sam.alertWithoutWezSince = 0 -- real threat in WEZ, reset
elseif sam.alertWithoutWezSince == 0 then
-- First poll ALERT with no WEZ contact: start frustration clock
sam.alertWithoutWezSince = now
sam.alertFrustrationTimeout = math.random(self.alertFrustrationMin, self.alertFrustrationMax)
elseif (now - sam.alertWithoutWezSince) >= sam.alertFrustrationTimeout then
-- Timeout: crew decides to stay hot or power down
if math.random(1, 100) <= self.alertFrustrationStayPct then
sam.alertWithoutWezSince = now
sam.alertFrustrationTimeout = math.random(self.alertFrustrationMin, self.alertFrustrationMax)
self:_Log(samName .. ": alert frustration — crew stays hot (rolled stay)")
else
local cooldown = math.random(self.alertFrustrationMin, self.alertFrustrationMax)
self:_Log(samName .. ": alert frustration — powering down (no WEZ contact for "
.. math.floor(now - sam.alertWithoutWezSince) .. "s, cooldown " .. cooldown .. "s)")
sam.alertWithoutWezSince = 0
sam.frustrationCooldownUntil = now + cooldown
self:_ApplyState(samName, "sam", AEGIS.STATE.AWARE)
end
end
end
else
-- Outside actRange AND (not ALERT or nothing in full WEZ)
sam.alertWithoutWezSince = 0
if self.debug and nearestDist then
self:_Log(string.format("%s: nearest contact %.1f NM (actRange %.0f NM)",
samName, nearestDist, actRange))
end
-- Sector jammed but clean contacts elsewhere: this SAM has no contacts
-- in its own actRange. On-axis SAMs (within jam pie cone) go jammed EMCON.
-- Off-axis SAMs stay on the network — EW feed still has clean contacts.
if self.eaEnabled and sectorJammed then
local actNM = self:_GetActRange(sam)
if self:_JammerOnAxis(sam, actNM, sectorJamPies) then
self:_Log(samName .. ": sector JAMMED, threat axis (jammed EMCON)")
self:_StartJammedEMCON(samName)
elseif sam.state == AEGIS.STATE.ALERT then
self:_ApplyState(samName, "sam", AEGIS.STATE.AWARE)
elseif sam.state == AEGIS.STATE.DARK then
self:_ApplyState(samName, "sam", AEGIS.STATE.AWARE)
end
elseif sam.state == AEGIS.STATE.ALERT then
self:_ApplyState(samName, "sam", AEGIS.STATE.AWARE)
elseif sam.state == AEGIS.STATE.DARK then
self:_ApplyState(samName, "sam", AEGIS.STATE.AWARE)
end
end
else
sam.alertWithoutWezSince = 0
if sam.state == AEGIS.STATE.ALERT
and (now - sam.lastContactTime) > self.alertTimeout then
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
elseif sam.state == AEGIS.STATE.AWARE then
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
end
end
end
end
end -- harm cooldown
end -- node has power
-- EA jammer effect: jammed EMCON cycling
-- Jammer is reactive — only affects SAMs that are emitting
if self.eaEnabled then
if self:_IsEmitting(sam) and not sam.jammedEmconActive then
local jammed = self:_IsJammed(sam)
if jammed then
-- SAM is emitting + jammed + not already in jammed EMCON
-- Schedule jam detection delay before crew shuts down
local delay = math.random(self.jamDetectionDelayMin, self.jamDetectionDelayMax)
local aegis = self
local sn = samName
timer.scheduleFunction(function()
local s = aegis.samSites[sn]
if not s or s.state == AEGIS.STATE.DESTROYED then return nil end
if s.harmCooldownUntil > timer.getTime() then return nil end
aegis:_StartJammedEMCON(sn)
return nil
end, nil, timer.getTime() + delay)
end
elseif sam.jammedEmconActive then
-- In jammed EMCON: check if jammer left (during off-phase)
local stillJammed = self:_IsJammed(sam)
if not stillJammed then
self:_StopJammedEMCON(samName)
self:_Log(samName .. ": jammer gone (poll), exiting jammed EMCON")
end
else
-- Not emitting and not in jammed EMCON: clear visual flag
if sam.jammed then
sam.jammed = false
end
end
end
end
end
-- PD slaving for EW-parented PDs only (SAM-parented PDs update via _ApplyState)
for _, pdName in ipairs(sector.pds) do
local pd = self.pdSites[pdName]
if pd and pd.state ~= AEGIS.STATE.DESTROYED and pd.parent then
local parentEw = self.ewRadars[pd.parent]
if parentEw then
local parentState = parentEw.hasContacts and AEGIS.STATE.ALERT or AEGIS.STATE.DARK
if parentState == AEGIS.STATE.ALERT then
self:_ApplyState(pdName, "pd", AEGIS.STATE.ALERT)
else
self:_ApplyState(pdName, "pd", AEGIS.STATE.DARK)
end
end
end
end
-- _AUTO sector: process autonomous SAMs once per full rotation
-- (autonomous SAMs are managed by EMCON timers — poll just handles EA checks)
if sector._fullRotation then
local autoSector = self.sectors["_AUTO"]
if autoSector then
for _, samName in ipairs(autoSector.sams) do
local sam = self.samSites[samName]
if sam and sam.state ~= AEGIS.STATE.DESTROYED and self.eaEnabled then
if self:_IsEmitting(sam) and not sam.jammedEmconActive then
local jammed = self:_IsJammed(sam)
if jammed then
local delay = math.random(self.jamDetectionDelayMin, self.jamDetectionDelayMax)
local aegis = self
local sn = samName
timer.scheduleFunction(function()
local s = aegis.samSites[sn]
if not s or s.state == AEGIS.STATE.DESTROYED then return nil end
if s.harmCooldownUntil > timer.getTime() then return nil end
aegis:_StartJammedEMCON(sn)
return nil
end, nil, timer.getTime() + delay)
end
elseif sam.jammedEmconActive then
local stillJammed = self:_IsJammed(sam)
if not stillJammed then
self:_StopJammedEMCON(samName)
self:_Log(samName .. ": jammer gone (poll), exiting jammed EMCON")
end
else
if sam.jammed then
sam.jammed = false
end
end
end
end
end
end
-- Clear accumulated state
sector._contacts = nil
sector._contactCount = nil
sector._jammed = nil
sector._jamBearing = nil
sector._jamPies = nil
sector._hasEW = nil
sector._fullRotation = nil
end
---------------------------------------------------------------------------
-- WEZ CHECK
---------------------------------------------------------------------------
--- Check if any contact is within this SAM's engagement zone and altitude band.
-- Respects per-site range overrides from naming convention or API.
-- Uses squared distance to avoid sqrt.
-- @return #boolean true if at least one contact is in the zone
function AEGIS:_CheckWEZ(sam, contacts)
if not sam.pos or not sam.sysData then return false end
local zone = self.siteZoneOverrides[sam.name] or self.defaultZone
local siteRange = self.siteRangeOverrides[sam.name]
local rangeNM
if zone == "NEZ" then
rangeNM = (siteRange and siteRange.nez) or sam.sysData.nez
else
rangeNM = (siteRange and siteRange.wez) or sam.sysData.wez
end
local rangeM = rangeNM * AEGIS.NM_TO_M
local rangeSq = rangeM * rangeM
local altMin = sam.sysData.altMin -- feet
local altMax = sam.sysData.altMax -- feet
for _, contact in ipairs(contacts) do
-- Altitude check
if contact.alt >= altMin and contact.alt <= altMax then
-- Range check (2D horizontal, squared -- no sqrt needed)
local dx = sam.pos.x - contact.pos.x
local dz = sam.pos.z - contact.pos.z
local horizDistSq = dx*dx + dz*dz
if horizDistSq <= rangeSq then
return true
end
end
end
return false
end
--- Check if any contact is within this SAM's full (system-rated) WEZ.
-- Ignores zone overrides (NEZ/WEZ) — always uses sysData.wez.
-- Used for frustration gating on ALERT SAMs: once the ambush is sprung,
-- the SAM fights with its full capability, not the restricted NEZ.
-- @return #boolean true if at least one contact is in the full WEZ
function AEGIS:_CheckFullWEZ(sam, contacts)
if not sam.pos or not sam.sysData then return false end
local rangeNM = sam.sysData.wez
local rangeM = rangeNM * AEGIS.NM_TO_M
local rangeSq = rangeM * rangeM
local altMin = sam.sysData.altMin
local altMax = sam.sysData.altMax
for _, contact in ipairs(contacts) do
if contact.alt >= altMin and contact.alt <= altMax then
local dx = sam.pos.x - contact.pos.x
local dz = sam.pos.z - contact.pos.z
local horizDistSq = dx*dx + dz*dz
if horizDistSq <= rangeSq then
return true
end
end
end
return false
end
--- Compute the effective activation range for a SAM in NM.
-- ACT suffix always wins. Otherwise, derive from active zone + system margin.
-- Margin = sysData.actRange - sysData.wez (the DCS AI lead time baked into each system).
-- NEZ/WEZ override shifts the engagement zone; margin preserves the same lead time.
function AEGIS:_GetActRange(sam)
local actNM = self.siteActRangeOverrides[sam.name]
if not actNM then
local zone = self.siteZoneOverrides[sam.name] or self.defaultZone
local siteRange = self.siteRangeOverrides[sam.name]
local margin = (sam.sysData.actRange or sam.sysData.wez) - sam.sysData.wez
if zone == "NEZ" then
local nez = (siteRange and siteRange.nez) or sam.sysData.nez
actNM = nez + margin
else
local wez = (siteRange and siteRange.wez) or sam.sysData.wez
actNM = wez + margin
end
end
return actNM
end
--- Check if any active jammer is within a given range of a SAM.
-- Used for per-SAM jammer proximity gate (only SAMs near a jammer go autonomous).
--- Check if a SAM is on the jammer's threat axis: both within actRange of an
-- active jammer AND inside at least one EW jam pie cone. Two gates (AND):
-- distance gate ensures a jammer is physically nearby, bearing gate ensures
-- the SAM sits in the approach corridor the jammer is actually protecting.
-- @param sam SAM record (needs .pos)
-- @param rangeNM activation range in NM (distance gate radius)
-- @param jamPies array of {ewX, ewZ, jdx, jdz, cosHalf} from EW loop
-- @return true if SAM is on-axis (jammer nearby AND within any EW's jam pie)
function AEGIS:_JammerOnAxis(sam, rangeNM, jamPies)
if not sam.pos or #jamPies == 0 then return false end
-- Distance gate: any active jammer within actRange?
local rangeSq = (rangeNM * AEGIS.NM_TO_M) ^ 2
local jammerNearby = false
local nearestJamNM
for _, j in pairs(self.jammers) do
if j.alive and j.active and j.pos then
local dx = sam.pos.x - j.pos.x
local dz = sam.pos.z - j.pos.z
local dSq = dx*dx + dz*dz
if dSq <= rangeSq then
jammerNearby = true
if not nearestJamNM then
nearestJamNM = math.sqrt(dSq) / AEGIS.NM_TO_M
end
break
elseif self.debug then
local dNM = math.sqrt(dSq) / AEGIS.NM_TO_M
if not nearestJamNM or dNM < nearestJamNM then nearestJamNM = dNM end
end
end
end
if not jammerNearby then
if self.debug and nearestJamNM then
self:_Log(string.format("%s: on-axis FAIL — distance gate (nearest jammer %.0f NM, actRange %.0f NM)",
sam.name, nearestJamNM, rangeNM))
end
return false
end
-- Bearing gate: is SAM within NEAREST EW's jam pie?
-- Uses nearest EW only — far EWs' pies can false-positive through multi-EW formations.
local nearestPie = nil
local nearestDistSq = math.huge
for _, pie in ipairs(jamPies) do
local ex = sam.pos.x - pie.ewX
local ez = sam.pos.z - pie.ewZ
local dSq = ex*ex + ez*ez
if dSq < nearestDistSq then
nearestDistSq = dSq
nearestPie = pie
end
end
if nearestPie then
local sx = sam.pos.x - nearestPie.ewX
local sz = sam.pos.z - nearestPie.ewZ
local dist = math.sqrt(nearestDistSq)
if dist > 0 then
local dot = (sx * nearestPie.jdx + sz * nearestPie.jdz) / dist
if dot >= nearestPie.cosHalf then
if self.debug then
local offDeg = math.deg(math.acos(math.min(dot, 1)))
local halfDeg = math.deg(math.acos(nearestPie.cosHalf))
self:_Log(string.format("%s: on-axis PASS — jammer %.0f NM, offset %.0f° (nearest EW pie ±%.0f°)",
sam.name, nearestJamNM, offDeg, halfDeg))
end
return true
end
if self.debug then
local offDeg = math.deg(math.acos(math.max(math.min(dot, 1), -1)))
local halfDeg = math.deg(math.acos(nearestPie.cosHalf))
self:_Log(string.format("%s: on-axis FAIL — bearing gate (offset %.0f°, nearest EW pie ±%.0f°)",
sam.name, offDeg, halfDeg))
end
end
end
return false
end
--- Check if any contact is within this SAM's activation range.
-- Used in integrated mode to go ALERT (start tracking) before WEZ.
-- actRange > WEZ gives the DCS AI time to build a fire solution.
-- @return #boolean true if at least one contact is in activation range
-- @return #number nearestDistNM — distance to nearest alt-valid contact (nil if none)
-- @return #number actNM — effective activation range used
function AEGIS:_CheckActivation(sam, contacts)
if not sam.pos or not sam.sysData then return false end
local actNM = self:_GetActRange(sam)
local rangeM = actNM * AEGIS.NM_TO_M
local rangeSq = rangeM * rangeM
local altMin = sam.sysData.altMin
local altMax = sam.sysData.altMax
local nearestSq = math.huge
for _, contact in ipairs(contacts) do
if contact.alt >= altMin and contact.alt <= altMax then
local dx = sam.pos.x - contact.pos.x
local dz = sam.pos.z - contact.pos.z
local horizDistSq = dx*dx + dz*dz
if horizDistSq <= rangeSq then
return true, 0, actNM
end
if horizDistSq < nearestSq then
nearestSq = horizDistSq
end
end
end
if nearestSq < math.huge then
return false, math.sqrt(nearestSq) / AEGIS.NM_TO_M, actNM
end
return false
end
-- Converts getDetectedTargets() output to contact format for _CheckWEZ().
-- @return #boolean true if at least one detected target is in the WEZ
function AEGIS:_DetectedInWEZ(sam, detected)
if not detected or #detected == 0 then return false end
local contacts = {}
for _, det in ipairs(detected) do
if det.object and det.object:isExist() then
local validContact = true
-- Filter out weapons (bombs, missiles) but allow decoys
local catOk, objCat = pcall(function() return det.object:getCategory() end)
if catOk and objCat == 2 then
validContact = false
local descOk, desc = pcall(function() return det.object:getDesc() end)
if descOk and desc and desc.missileCategory == 6 and desc.guidance == 1 then
validContact = true -- Decoy
end
end
if validContact then
local pos = det.object:getPoint()
if pos then
table.insert(contacts, {
pos = pos,
alt = pos.y / AEGIS.FT_TO_M,
})
end
end
end
end
return self:_CheckWEZ(sam, contacts)
end
---------------------------------------------------------------------------
-- EMCON CYCLING (Phase 2 core feature)
---------------------------------------------------------------------------
--- Start EMCON cycling for a SAM. Increments generation to invalidate
--- any pending timers from a previous EMCON cycle.
--- Adds random startup jitter so SAMs don't all start cycling in sync.
function AEGIS:_StartEMCON(samName)
local sam = self.samSites[samName]
if not sam then return end
-- Already in EMCON?
if sam.state == AEGIS.STATE.EMCON_ON
or sam.state == AEGIS.STATE.EMCON_OFF
or sam.state == AEGIS.STATE.EMCON_ENGAGED then
return
end
sam.emconGen = sam.emconGen + 1
sam.sweepsSinceDetect = 0
sam.lastSweepHadContact = false
-- Random startup delay so SAMs desynchronize immediately
local jitter = math.random(0, self.emconStartupJitter)
self:_Log(samName .. ": entering EMCON cycle (start delay " .. jitter .. "s)")
local gen = sam.emconGen
local aegis = self
if jitter > 0 then
self:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_ON) -- silent during jitter
timer.scheduleFunction(function()
if not sam or sam.emconGen ~= gen then return nil end
aegis:_EmconSilentPhase(samName, gen)
end, nil, timer.getTime() + jitter)
else
self:_EmconSilentPhase(samName, gen)
end
end
--- Stop EMCON and return to normal network operation.
function AEGIS:_StopEMCON(samName)
local sam = self.samSites[samName]
if not sam then return end
sam.emconGen = sam.emconGen + 1 -- invalidate pending timers
self:_Log(samName .. ": leaving EMCON")
end
--- EMCON silent phase: emissions off for random duration, then sweep.
--- Duration scales based on threat memory and spook state.
function AEGIS:_EmconSilentPhase(samName, gen)
local sam = self.samSites[samName]
if not sam or sam.emconGen ~= gen then return end
self:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_ON)
-- Base silent duration
local minDur = self.emconOnMin
local maxDur = self.emconOnMax
-- Threat memory: recent contact = shorter silence (crew is anxious)
if sam.lastSweepHadContact then
minDur = math.floor(minDur * self.emconThreatScale)
maxDur = math.floor(maxDur * self.emconThreatScale)
-- Relaxed: 3+ empty sweeps = longer silence (crew thinks it's safe)
elseif sam.sweepsSinceDetect >= 3 then
minDur = math.floor(minDur * self.emconRelaxedScale)
maxDur = math.floor(maxDur * self.emconRelaxedScale)
end
-- Spooked: nearby SAM was killed, crew goes extra quiet
local now = timer.getTime()
if self.emconSpookEnabled and sam.spooked and now < sam.spookedUntil then
minDur = math.max(minDur, self.emconSpookDuration)
maxDur = math.max(maxDur, self.emconSpookDuration + 60)
self:_Log(samName .. ": SPOOKED, extended silence")
sam.spooked = false -- one extended cycle, then back to normal
end
-- Clamp min <= max
if minDur > maxDur then maxDur = minDur end
if minDur < 5 then minDur = 5 end
local offDuration = math.random(minDur, maxDur)
local aegis = self
timer.scheduleFunction(function()
if not sam or sam.emconGen ~= gen then return nil end
if sam.state == AEGIS.STATE.DESTROYED then return nil end
-- Check if we should still be in EMCON (maybe network was restored)
local sector = sam.sector
if sector and sector ~= "_AUTO" then
if aegis:_SectorHasEW(sector) and aegis:_SectorHasC2(sector)
and aegis:_NodeHasPower(sam) then
aegis:_StopEMCON(samName)
aegis:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
return nil
end
end
aegis:_EmconSweepPhase(samName, gen)
end, nil, timer.getTime() + offDuration)
end
--- EMCON sweep phase: search radar on, weapon hold. After detect delay,
--- check for targets in WEZ. If found, break EMCON and engage.
--- May terminate early (quick peek) or double-sweep based on probability.
function AEGIS:_EmconSweepPhase(samName, gen)
local sam = self.samSites[samName]
if not sam or sam.emconGen ~= gen then return end
self:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_OFF)
-- PB HARM inbound: SAM just turned radar on during EMCON sweep — check harmInbound
if self:_TriggerHarmInboundReaction(samName) then return end
-- Chance of early termination (quick peek: ~3-5 seconds then back to silent)
local isQuickPeek = math.random(1, 100) <= self.emconEarlyTerm
local onDuration
if isQuickPeek then
onDuration = math.random(3, 6)
self:_Log(samName .. ": quick peek (" .. onDuration .. "s)")
else
onDuration = math.random(self.emconOffMin, self.emconOffMax)
end
local aegis = self
-- After detect delay, check for targets
timer.scheduleFunction(function()
if not sam or sam.emconGen ~= gen then return nil end
if sam.state ~= AEGIS.STATE.EMCON_OFF then return nil end
local grp = Group.getByName(samName)
if not grp or not grp:isExist() then
sam.state = AEGIS.STATE.DESTROYED
return nil
end
local ctrl = grp:getController()
local detected = ctrl:getDetectedTargets(Controller.Detection.RADAR)
if aegis:_DetectedInWEZ(sam, detected) then
-- BREAK EMCON -- target in WEZ
aegis:_Log(samName .. ": EMCON BREAK - contact in WEZ!")
sam.lastContactTime = timer.getTime()
sam.lastSweepHadContact = true
sam.sweepsSinceDetect = 0
-- PB HARM check: SAM about to go weapons free, but HARM may be inbound
if aegis:_TriggerHarmInboundReaction(samName) then return nil end
aegis:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_ENGAGED)
aegis:_UpdatePDsForParent(samName)
aegis:_EmconEngagedMonitor(samName, gen)
else
-- Track threat memory: did we see anything at all? (even outside WEZ)
local sawAnything = detected and #detected > 0
if isQuickPeek then
-- Quick peek done, back to silent
sam.lastSweepHadContact = sawAnything
if not sawAnything then
sam.sweepsSinceDetect = sam.sweepsSinceDetect + 1
else
sam.sweepsSinceDetect = 0
end
aegis:_EmconSilentPhase(samName, gen)
else
-- Full sweep: schedule second check near end of window
local remaining = onDuration - aegis.emconDetectDelay
if remaining > 5 then
timer.scheduleFunction(function()
if not sam or sam.emconGen ~= gen then return nil end
if sam.state ~= AEGIS.STATE.EMCON_OFF then return nil end
local g2 = Group.getByName(samName)
if g2 and g2:isExist() then
local c2 = g2:getController()
local d2 = c2:getDetectedTargets(Controller.Detection.RADAR)
if aegis:_DetectedInWEZ(sam, d2) then
aegis:_Log(samName .. ": EMCON BREAK (2nd check) - contact in WEZ")
sam.lastContactTime = timer.getTime()
sam.lastSweepHadContact = true
sam.sweepsSinceDetect = 0
-- PB HARM check: SAM about to go weapons free
if aegis:_TriggerHarmInboundReaction(samName) then return nil end
aegis:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_ENGAGED)
aegis:_UpdatePDsForParent(samName)
aegis:_EmconEngagedMonitor(samName, gen)
return nil
end
-- Update threat memory with second check
local saw2 = d2 and #d2 > 0
sawAnything = sawAnything or saw2
end
-- Sweep complete. Update threat memory.
sam.lastSweepHadContact = sawAnything
if not sawAnything then
sam.sweepsSinceDetect = sam.sweepsSinceDetect + 1
else
sam.sweepsSinceDetect = 0
end
-- Chance of double-sweep: brief pause then sweep again
-- Only triggers if we saw something (outside WEZ) -- crew wants another look
if sawAnything and math.random(1, 100) <= aegis.emconDoubleSweep then
aegis:_Log(samName .. ": double-sweep (contact outside WEZ)")
aegis:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_ON)
timer.scheduleFunction(function()
if not sam or sam.emconGen ~= gen then return nil end
aegis:_EmconSweepPhase(samName, gen)
end, nil, timer.getTime() + math.random(5, 10))
else
aegis:_EmconSilentPhase(samName, gen)
end
end, nil, timer.getTime() + remaining)
else
-- Sweep window too short for second check
sam.lastSweepHadContact = sawAnything
if not sawAnything then
sam.sweepsSinceDetect = sam.sweepsSinceDetect + 1
else
sam.sweepsSinceDetect = 0
end
aegis:_EmconSilentPhase(samName, gen)
end
end
end
end, nil, timer.getTime() + self.emconDetectDelay)
end
--- Monitor an EMCON-engaged SAM. When it loses targets for X seconds,
--- re-enter EMCON cycle. Timeout is rolled randomly per engagement.
function AEGIS:_EmconEngagedMonitor(samName, gen)
local sam = self.samSites[samName]
if not sam or sam.emconGen ~= gen then return end
local aegis = self
local checkInterval = 5 -- check every 5s (tight loop for short timeouts)
local reengageTimeout = math.random(self.emconReengageMin, self.emconReengageMax)
aegis:_Log(samName .. ": reengage timeout " .. reengageTimeout .. "s")
local function monitor()
if not sam or sam.emconGen ~= gen then return nil end
if sam.state ~= AEGIS.STATE.EMCON_ENGAGED then return nil end
-- Check if network was restored
if sam.sector and sam.sector ~= "_AUTO" then
if aegis:_SectorHasEW(sam.sector) and aegis:_SectorHasC2(sam.sector)
and aegis:_NodeHasPower(sam) then
aegis:_StopEMCON(samName)
aegis:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
return nil
end
end
local grp = Group.getByName(samName)
if not grp or not grp:isExist() then
sam.state = AEGIS.STATE.DESTROYED
return nil
end
local ctrl = grp:getController()
local detected = ctrl:getDetectedTargets(Controller.Detection.RADAR)
if aegis:_DetectedInWEZ(sam, detected) then
sam.lastContactTime = timer.getTime()
end
if (timer.getTime() - sam.lastContactTime) > reengageTimeout then
aegis:_Log(samName .. ": engagement timeout, re-entering EMCON")
aegis:_EmconSilentPhase(samName, gen)
return nil
end
-- Keep monitoring
return timer.getTime() + checkInterval
end
timer.scheduleFunction(monitor, nil, timer.getTime() + checkInterval)
end
--- Update PD sites that are children of a given SAM.
--- Respects parent HARM cooldown — PD stays ALERT during HARM defense.
--- Uses pre-built pds list on SAM node (built at discovery time).
function AEGIS:_UpdatePDsForParent(parentName)
local parentSam = self.samSites[parentName]
if not parentSam or not parentSam.pds then return end
local now = timer.getTime()
for _, pdName in ipairs(parentSam.pds) do
local pd = self.pdSites[pdName]
if pd and pd.state ~= AEGIS.STATE.DESTROYED then
-- If parent is in active HARM cooldown, PD stays ALERT for defense
if parentSam.harmCooldownUntil and now < parentSam.harmCooldownUntil then
if pd.state ~= AEGIS.STATE.ALERT then
self:_ApplyState(pdName, "pd", AEGIS.STATE.ALERT)
end
elseif parentSam.state == AEGIS.STATE.ALERT
or parentSam.state == AEGIS.STATE.EMCON_ENGAGED then
self:_ApplyState(pdName, "pd", AEGIS.STATE.ALERT)
else
self:_ApplyState(pdName, "pd", AEGIS.STATE.DARK)
end
end
end
end
--- Promote an orphaned PD to an autonomous SAM.
--- Creates a full SAM entry from the PD's data, starts EMCON cycling.
function AEGIS:_PromoteOrphanPD(pdName)
local pd = self.pdSites[pdName]
if not pd or pd.state == AEGIS.STATE.DESTROYED then return end
local grp = Group.getByName(pdName)
if not grp or not grp:isExist() or grp:getSize() == 0 then return end
self:_Log(" " .. pdName .. ": orphan promoted to autonomous SAM", true)
-- Inherit sector from dead parent (entry still exists in samSites)
local sectorName = pd.sector or "_AUTO"
-- Create full SAM entry
self.samSites[pdName] = {
name = pdName,
sector = sectorName,
sysType = pd.sysType,
sysData = pd.sysData,
state = nil, -- nil forces _ApplyState to execute DCS commands
emconGen = 0,
lastContactTime = 0,
pos = pd.pos,
sweepsSinceDetect = 0,
lastSweepHadContact = false,
spooked = false,
spookedUntil = 0,
powerSource = nil,
harmCooldownUntil = 0,
harmEvents = {},
harmReaction = nil,
harmReactionGen = 0,
harmWeapon = nil,
harmReactionStart = 0,
harmInbound = 0,
harmInboundExpiry = 0,
alertWithoutWezSince = 0,
alertFrustrationTimeout = 0,
frustrationCooldownUntil = 0,
jammed = false,
jammedEmconGen = 0,
jammedEmconActive = false,
hojUntil = 0,
hojCooldownUntil = 0,
hojPeekCount = 0,
trackRadarUnit = nil, -- PD systems (SA-15/TOR) are self-contained, no critical unit
}
-- Add to sector SAM list
self:_EnsureSector(sectorName)
table.insert(self.sectors[sectorName].sams, pdName)
-- Remove from sector PD list
local sec = self.sectors[sectorName]
if sec then
for i, p in ipairs(sec.pds) do
if p == pdName then
table.remove(sec.pds, i)
break
end
end
end
-- Remove from pdSites
self.pdSites[pdName] = nil
-- Check if sector still has network support (parent dead ≠ sector dead)
local hasEW = self:_SectorHasEW(sectorName)
local hasC2 = self:_SectorHasC2(sectorName)
if hasEW and hasC2 then
-- Sector has EW coverage: start DARK, let poll handle activation
self:_ApplyState(pdName, "sam", AEGIS.STATE.DARK)
else
-- No network: autonomous EMCON cycling
self:_StartEMCON(pdName)
end
end
---------------------------------------------------------------------------
-- SECTOR STATUS QUERIES
---------------------------------------------------------------------------
--- Check if a specific node has power.
--- No powerSource linked = self-powered (always true).
--- PowerSource linked = check if that PWR group is alive.
function AEGIS:_NodeHasPower(node)
if not node.powerSource then return true end -- self-powered
local pwr = self.powerSources[node.powerSource]
if not pwr then return true end -- safety: missing PWR = assume powered
return pwr.alive
end
function AEGIS:_SectorHasC2(sectorName)
local sec = self.sectors[sectorName]
if not sec then return true end
if #sec.cmd == 0 then return true end
for _, c in ipairs(sec.cmd) do
if self.commandCenters[c] and self.commandCenters[c].alive then return true end
end
return false
end
function AEGIS:_SectorHasEW(sectorName)
local sec = self.sectors[sectorName]
if not sec then return false end
for _, e in ipairs(sec.ew) do
local n = self.ewRadars[e]
if n and n.state ~= AEGIS.STATE.DESTROYED then
local g = Group.getByName(e)
if g and g:isExist() and g:getSize() > 0 then return true end
end
end
return false
end
---------------------------------------------------------------------------
-- EVENT HANDLER (Deaths)
---------------------------------------------------------------------------
function AEGIS:_RegisterEventHandler()
local aegis = self
self.eventHandler = {
onEvent = function(_, event)
if event.id == world.event.S_EVENT_DEAD
or event.id == world.event.S_EVENT_UNIT_LOST then
-- Delay slightly for DCS state to settle
timer.scheduleFunction(function()
aegis:_OnDeath()
end, nil, timer.getTime() + 0.5)
elseif event.id == world.event.S_EVENT_SHOT then
-- HARM detection: process immediately (time-critical)
local ok, err = pcall(function() aegis:_OnShot(event) end)
if not ok then
aegis:_Log("HARM handler error: " .. tostring(err), true)
end
elseif event.id == world.event.S_EVENT_BIRTH then
-- Late activation / respawn: check if this is a pending or destroyed IADS node
local ok2, err2 = pcall(function() aegis:_OnBirthNode(event) end)
if not ok2 then
aegis:_Log("Birth node handler error: " .. tostring(err2), true)
end
-- EA aircraft discovery: client slots don't exist at mission start in MP
if aegis.eaEnabled then
local ok3, err3 = pcall(function() aegis:_OnBirthEA(event) end)
if not ok3 then
aegis:_Log("EA birth handler error: " .. tostring(err3), true)
end
end
elseif event.id == world.event.S_EVENT_PLAYER_LEAVE_UNIT and aegis.eaEnabled then
-- Player left EA slot — deactivate jammer
-- DCS fires this TWICE on slot change; second time initiator is nil
local ok4, err4 = pcall(function() aegis:_OnPlayerLeaveEA(event) end)
if not ok4 then
aegis:_Log("EA leave handler error: " .. tostring(err4), true)
end
end
end
}
world.addEventHandler(self.eventHandler)
self:_Log("Event handler registered (deaths + HARM detection + late activation"
.. (self.eaEnabled and " + EA discovery" or "") .. ")")
end
function AEGIS:_OnDeath()
-- Check all tracked nodes for destruction
for ewName, n in pairs(self.ewRadars) do
if n.state ~= AEGIS.STATE.DESTROYED then
local g = Group.getByName(ewName)
if not g or not g:isExist() or g:getSize() == 0 then
self:_Log("*** EW KILLED: " .. ewName)
n.state = AEGIS.STATE.DESTROYED
n.hasContacts = false
end
end
end
for pwrName, n in pairs(self.powerSources) do
if n.alive then
local g = Group.getByName(pwrName)
local s = StaticObject.getByName(pwrName)
if not ((g and g:isExist() and g:getSize() > 0) or (s and s:isExist())) then
self:_Log("*** PWR KILLED: " .. pwrName)
n.alive = false
-- Immediately force all linked nodes permanently dark
for _, targetName in ipairs(n.linkedTo) do
local sam = self.samSites[targetName]
if sam and sam.state ~= AEGIS.STATE.DESTROYED then
self:_Log(" " .. targetName .. ": lost power, permanently DARK")
self:_StopEMCON(targetName)
self:_ApplyState(targetName, "sam", AEGIS.STATE.DARK)
end
local ew = self.ewRadars[targetName]
if ew and ew.state ~= AEGIS.STATE.DESTROYED then
self:_Log(" " .. targetName .. ": lost power, EW offline")
ew.state = AEGIS.STATE.DESTROYED -- treat as dead
ew.hasContacts = false
-- Kill radar — ALARM_STATE=GREEN is what actually stops EW search radar
local ewGrp = Group.getByName(targetName)
if ewGrp and ewGrp:isExist() then
local ctrl = ewGrp:getController()
ctrl:setOption(AI.Option.Ground.id.ALARM_STATE, AEGIS.ALARM.GREEN)
ewGrp:enableEmission(false)
end
end
end
end
end
end
for cmdName, n in pairs(self.commandCenters) do
if n.alive then
local g = Group.getByName(cmdName)
if not g or not g:isExist() or g:getSize() == 0 then
self:_Log("*** CMD KILLED: " .. cmdName)
n.alive = false
end
end
end
for samName, n in pairs(self.samSites) do
if n.state ~= AEGIS.STATE.DESTROYED then
local killed = false
local missionKill = false
local killReason = nil
-- Tier 1: Critical unit checks (mission kill)
if n.trackRadarUnit then
local trUnit = Unit.getByName(n.trackRadarUnit)
if not trUnit or not trUnit:isExist() then
killed = true
missionKill = true
killReason = "tracking radar destroyed"
end
end
if not killed and n.commandPostUnit then
local cpUnit = Unit.getByName(n.commandPostUnit)
if not cpUnit or not cpUnit:isExist() then
killed = true
missionKill = true
killReason = "command post destroyed"
end
end
-- Tier 2: Full group check (fallback for self-contained systems, or group wiped)
if not killed then
local g = Group.getByName(samName)
if not g or not g:isExist() or g:getSize() == 0 then
killed = true
end
end
if killed then
self:_StopEMCON(samName)
if missionKill then
self:_Log("*** SAM MISSION KILL: " .. samName .. " (" .. killReason .. ")", true)
else
self:_Log("*** SAM KILLED: " .. samName)
end
n.state = AEGIS.STATE.DESTROYED
-- Silence surviving units on mission kill (group still alive but combat-ineffective)
if missionKill then
local g = Group.getByName(samName)
if g and g:isExist() then
local ctrl = g:getController()
ctrl:setOption(AI.Option.Ground.id.ALARM_STATE, AEGIS.ALARM.GREEN)
g:enableEmission(false)
ctrl:setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
end
end
-- Spook nearby EMCON SAMs -- a neighbor just died, extend their silence
if self.emconSpookEnabled and n.pos then
local spookRange = 30 * AEGIS.NM_TO_M -- 30 NM
local spookRangeSq = spookRange * spookRange
for otherName, other in pairs(self.samSites) do
if other ~= n and other.state ~= AEGIS.STATE.DESTROYED and other.pos then
local dx = n.pos.x - other.pos.x
local dz = n.pos.z - other.pos.z
if (dx*dx + dz*dz) <= spookRangeSq then
local inEmcon = (other.state == AEGIS.STATE.EMCON_ON
or other.state == AEGIS.STATE.EMCON_OFF
or other.state == AEGIS.STATE.EMCON_ENGAGED)
if inEmcon then
other.spooked = true
other.spookedUntil = timer.getTime() + self.emconSpookDuration
self:_Log(" " .. otherName .. ": SPOOKED by " .. samName .. " death")
end
end
end
end
end
end
end
end
for pdName, n in pairs(self.pdSites) do
if n.state ~= AEGIS.STATE.DESTROYED then
local g = Group.getByName(pdName)
if not g or not g:isExist() or g:getSize() == 0 then
self:_Log("*** PD KILLED: " .. pdName)
n.state = AEGIS.STATE.DESTROYED
end
end
end
-- Orphan promotion: PDs whose parent was just destroyed become autonomous SAMs
-- Deferred to avoid modifying pdSites/samSites during the loops above
local orphans = {}
for pdName, pd in pairs(self.pdSites) do
if pd.state ~= AEGIS.STATE.DESTROYED and pd.parent then
local parentSam = self.samSites[pd.parent]
if parentSam and parentSam.state == AEGIS.STATE.DESTROYED then
table.insert(orphans, pdName)
end
end
end
for _, pdName in ipairs(orphans) do
self:_PromoteOrphanPD(pdName)
end
-- EA jammers: check for dead jammer aircraft
for jamName, j in pairs(self.jammers) do
if j.alive then
local g = Group.getByName(jamName)
if not g or not g:isExist() or g:getSize() == 0 then
self:_Log("*** EA KILLED: " .. jamName, true)
j.alive = false
j.active = false
-- Clear player mappings for this dead jammer
for pName, gName in pairs(self.jammerPlayers) do
if gName == jamName then
self.jammerPlayers[pName] = nil
end
end
end
end
end
-- Note: SAMs in sectors that just lost EW/C2 will transition to EMCON
-- on the next EW poll cycle (within ewPollInterval seconds).
-- This is intentional -- avoids re-evaluating everything on every death.
end
---------------------------------------------------------------------------
-- LATE ACTIVATION / RESPAWN (S_EVENT_BIRTH handler)
---------------------------------------------------------------------------
--- Handle S_EVENT_BIRTH for IADS nodes: late activation and respawn.
--- Deduplicates multi-unit groups (SA-10 = 18 BIRTH events) via activated flag.
function AEGIS:_OnBirthNode(event)
if not event.initiator then return end
local ok, unit = pcall(function() return event.initiator end)
if not ok or not unit then return end
-- Must be our coalition
local unitCoal = unit:getCoalition()
if unitCoal ~= self.coalitionId then return end
local grpOk, grp = pcall(function() return unit:getGroup() end)
if not grpOk or not grp then return end
local groupName = grp:getName()
-- Path 1: Late activation — group was pending from _AutoDiscover
local pending = self.pendingNodes[groupName]
if pending and not pending.activated then
pending.activated = true -- dedup: ignore subsequent unit BIRTHs
self:_Log("*** BIRTH (late activation): " .. groupName .. " [" .. pending.nodeType .. "]", true)
local aegis = self
timer.scheduleFunction(function()
aegis:_ActivatePendingNode(groupName)
end, nil, timer.getTime() + 2)
return
end
-- Path 2: Respawn — destroyed node receiving new BIRTH
local sam = self.samSites[groupName]
if sam and sam.state == AEGIS.STATE.DESTROYED then
-- Dedup: use a respawn flag to ignore subsequent unit BIRTHs
if sam._respawnPending then return end
sam._respawnPending = true
self:_Log("*** BIRTH (respawn): " .. groupName .. " [sam]", true)
local aegis = self
timer.scheduleFunction(function()
aegis:_RespawnNode(groupName, "sam")
end, nil, timer.getTime() + 2)
return
end
local ew = self.ewRadars[groupName]
if ew and ew.state == AEGIS.STATE.DESTROYED then
if ew._respawnPending then return end
ew._respawnPending = true
self:_Log("*** BIRTH (respawn): " .. groupName .. " [ew]", true)
local aegis = self
timer.scheduleFunction(function()
aegis:_RespawnNode(groupName, "ew")
end, nil, timer.getTime() + 2)
return
end
local pd = self.pdSites[groupName]
if pd and pd.state == AEGIS.STATE.DESTROYED then
if pd._respawnPending then return end
pd._respawnPending = true
self:_Log("*** BIRTH (respawn): " .. groupName .. " [pd]", true)
local aegis = self
timer.scheduleFunction(function()
aegis:_RespawnNode(groupName, "pd")
end, nil, timer.getTime() + 2)
return
end
end
--- Activate a pending (late-activation) node. Called T+2s after first BIRTH.
function AEGIS:_ActivatePendingNode(groupName)
local pending = self.pendingNodes[groupName]
if not pending then return end
local grp = Group.getByName(groupName)
if not grp or not grp:isExist() then
self:_Log("PENDING activation failed (group gone): " .. groupName, true)
self.pendingNodes[groupName] = nil
return
end
local nodeType = pending.nodeType
self:_Log("Activating pending node: " .. groupName .. " [" .. nodeType .. "]")
if nodeType == "sam" then
-- Apply pre-parsed overrides before registration
if pending.zoneOverride then
self.siteZoneOverrides[groupName] = pending.zoneOverride
if pending.rangeOverride then
self.siteRangeOverrides[groupName] = self.siteRangeOverrides[groupName] or {}
if pending.zoneOverride == "WEZ" then
self.siteRangeOverrides[groupName].wez = pending.rangeOverride
elseif pending.zoneOverride == "NEZ" then
self.siteRangeOverrides[groupName].nez = pending.rangeOverride
end
end
end
if pending.actOverride then
self.siteActRangeOverrides[groupName] = pending.actOverride
end
if pending.sector and not self.explicitSectors[groupName] then
self.explicitSectors[groupName] = pending.sector
end
self:_RegisterSAM(groupName)
local sam = self.samSites[groupName]
if not sam then
self.pendingNodes[groupName] = nil
return
end
self:_AssociateSingleSAM(groupName, sam)
self:_LinkPowerForNode(groupName, "sam")
self:_AssociatePDsForSAM(groupName, sam)
-- Silence emissions before state evaluation
grp:enableEmission(false)
grp:getController():setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
self:_SnapshotInitSAM(groupName, sam)
if sam.mobile then self:_RebuildMobileList() end
elseif nodeType == "ew" then
self:_RegisterEW(groupName, pending.sector)
local ew = self.ewRadars[groupName]
if not ew then
self.pendingNodes[groupName] = nil
return
end
self:_LinkPowerForNode(groupName, "ew")
if not self:_NodeHasPower(ew) then
self:_Log(groupName .. ": power dead on activation, permanently offline")
ew.state = AEGIS.STATE.DESTROYED
local ewGrp = Group.getByName(groupName)
if ewGrp and ewGrp:isExist() then
local ctrl = ewGrp:getController()
ctrl:setOption(AI.Option.Ground.id.ALARM_STATE, AEGIS.ALARM.GREEN)
ewGrp:enableEmission(false)
end
else
self:_UpgradeSectorForEW(ew.sector)
self:_RebuildPollOrder()
end
elseif nodeType == "pd" then
self:_RegisterPD(groupName, nil)
local pd = self.pdSites[groupName]
if not pd then
self.pendingNodes[groupName] = nil
return
end
self:_AssociateSinglePD(groupName)
self:_LinkPowerForNode(groupName, "pd")
-- Silence emissions
grp:enableEmission(false)
grp:getController():setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
-- Mirror parent state or promote to orphan
if pd.parent then
local parentSam = self.samSites[pd.parent]
if parentSam and parentSam.state ~= AEGIS.STATE.DESTROYED then
if parentSam.state == AEGIS.STATE.ALERT or parentSam.state == AEGIS.STATE.EMCON_ENGAGED then
self:_ApplyState(groupName, "pd", AEGIS.STATE.ALERT)
else
self:_ApplyState(groupName, "pd", AEGIS.STATE.DARK)
end
else
-- Parent dead or doesn't exist — promote to autonomous SAM
self:_PromoteOrphanPD(groupName)
end
else
self:_ApplyState(groupName, "pd", AEGIS.STATE.DARK)
end
end
self.pendingNodes[groupName] = nil
self:_Log("Pending node activated: " .. groupName)
end
--- Snapshot state evaluation for a single SAM activated mid-mission.
--- Mirrors _SetInitialState() logic but accounts for current sector health.
function AEGIS:_SnapshotInitSAM(groupName, sam)
-- Power source dead → permanently DARK
if not self:_NodeHasPower(sam) then
self:_Log(groupName .. ": power dead on activation, permanently DARK")
self:_ApplyState(groupName, "sam", AEGIS.STATE.DARK)
return
end
local sector = sam.sector
-- Autonomous SAMs always EMCON cycle
if sector == "_AUTO" then
self:_Log(groupName .. ": autonomous, starting EMCON (snapshot)")
self:_StartEMCON(groupName)
return
end
-- Check sector health
local hasEW = self:_SectorHasEW(sector)
local hasC2 = self:_SectorHasC2(sector)
if hasEW and hasC2 then
-- Integrated: start DARK, next EW poll handles activation
self:_ApplyState(groupName, "sam", AEGIS.STATE.DARK)
self:_Log(groupName .. ": integrated (sector " .. sector .. " healthy), DARK (snapshot)")
else
-- Degraded sector: EMCON cycling
self:_Log(groupName .. ": sector " .. sector .. " degraded (EW=" .. tostring(hasEW)
.. " C2=" .. tostring(hasC2) .. "), starting EMCON (snapshot)")
self:_StartEMCON(groupName)
end
-- If EA enabled and currently jammed, switch to jammed EMCON
if self.eaEnabled and self:_IsJammed(sam) then
self:_Log(groupName .. ": jammed on activation, starting jammed EMCON")
sam.jammed = true
self:_StopEMCON(groupName)
self:_StartJammedEMCON(groupName)
end
end
---------------------------------------------------------------------------
-- SINGLE-NODE ASSOCIATION HELPERS (for mid-mission activation)
---------------------------------------------------------------------------
--- Associate a single SAM to its sector. Mirrors _AutoAssociateSAMs() for one node.
function AEGIS:_AssociateSingleSAM(groupName, sam)
local explicit = self.explicitSectors[groupName]
if explicit then
sam.sector = explicit
self:_EnsureSector(explicit)
table.insert(self.sectors[explicit].sams, groupName)
self:_Log(" " .. groupName .. " -> " .. explicit .. " (explicit, late)")
return
end
if not sam.pos then
sam.sector = "_AUTO"
self:_EnsureSector("_AUTO")
table.insert(self.sectors["_AUTO"].sams, groupName)
self:_Log(" " .. groupName .. " -> AUTONOMOUS (no position)")
return
end
-- Find nearest EW
local best, bestDist = nil, math.huge
local threshold = self.autoAssocRange * AEGIS.NM_TO_M
for ewName, ew in pairs(self.ewRadars) do
if ew.pos then
local d = self:_Dist(sam.pos, ew.pos)
if d < bestDist then bestDist = d; best = ew end
end
end
if best and bestDist <= threshold then
sam.sector = best.sector
self:_EnsureSector(best.sector)
table.insert(self.sectors[best.sector].sams, groupName)
self:_Log(" " .. groupName .. " -> " .. best.sector
.. " (" .. math.floor(bestDist / AEGIS.NM_TO_M) .. " NM, late)")
else
sam.sector = "_AUTO"
self:_EnsureSector("_AUTO")
table.insert(self.sectors["_AUTO"].sams, groupName)
self:_Log(" " .. groupName .. " -> AUTONOMOUS (late)")
end
end
--- Associate a single PD to its parent. Mirrors _AutoAssociatePDs() for one node.
function AEGIS:_AssociateSinglePD(pdName)
local pd = self.pdSites[pdName]
if not pd or pd.parent then return end -- already has parent (explicit)
if not pd.pos then return end
local threshold = self.pdAssocRange * AEGIS.NM_TO_M
local best, bestDist, bestSector = nil, math.huge, nil
-- Find nearest AREA SAM
for samName, sam in pairs(self.samSites) do
if sam.sysData.cat == "AREA" and sam.pos and sam.state ~= AEGIS.STATE.DESTROYED then
local d = self:_Dist(pd.pos, sam.pos)
if d < bestDist then bestDist = d; best = samName; bestSector = sam.sector end
end
end
-- Also check EW radars
for ewName, ew in pairs(self.ewRadars) do
if ew.pos and ew.state ~= AEGIS.STATE.DESTROYED then
local d = self:_Dist(pd.pos, ew.pos)
if d < bestDist then bestDist = d; best = ewName; bestSector = ew.sector end
end
end
if best and bestDist <= threshold then
pd.parent = best
if bestSector then
pd.sector = bestSector
self:_EnsureSector(bestSector)
table.insert(self.sectors[bestSector].pds, pdName)
end
-- Add to parent's PD list
local parentSam = self.samSites[best]
if parentSam then
if not parentSam.pds then parentSam.pds = {} end
table.insert(parentSam.pds, pdName)
end
self:_Log(" " .. pdName .. " -> " .. best
.. " (" .. math.floor(bestDist / AEGIS.NM_TO_M * 10) / 10 .. " NM, late)")
else
self:_Log(" " .. pdName .. " -> NO PARENT (late)", true)
end
end
--- Find orphan PDs that should parent to a newly-activated SAM.
function AEGIS:_AssociatePDsForSAM(samName, sam)
if not sam.pos or sam.sysData.cat ~= "AREA" then return end
local threshold = self.pdAssocRange * AEGIS.NM_TO_M
for pdName, pd in pairs(self.pdSites) do
if not pd.parent and pd.pos then
local d = self:_Dist(pd.pos, sam.pos)
if d <= threshold then
pd.parent = samName
pd.sector = sam.sector
if sam.sector then
self:_EnsureSector(sam.sector)
table.insert(self.sectors[sam.sector].pds, pdName)
end
if not sam.pds then sam.pds = {} end
table.insert(sam.pds, pdName)
self:_Log(" " .. pdName .. " -> " .. samName .. " (adopted, late)")
end
end
end
end
--- Link power source to a single node by naming convention.
function AEGIS:_LinkPowerForNode(groupName, nodeType)
for pwrName, pwr in pairs(self.powerSources) do
if #pwr.linkedTo == 0 then -- only auto-link unlinked PWRs
local hint = pwr.targetHint
local target = nil
if nodeType == "sam" and ("SAM-" .. hint) == groupName then
target = self.samSites[groupName]
elseif nodeType == "ew" and hint == groupName then
target = self.ewRadars[groupName]
end
if target then
target.powerSource = pwrName
table.insert(pwr.linkedTo, groupName)
self:_Log(" PWR LINK (late): " .. pwrName .. " -> " .. groupName)
end
else
-- Check already-linked PWRs for this specific target
for _, linkedName in ipairs(pwr.linkedTo) do
if linkedName == groupName then return end -- already linked
end
end
end
end
---------------------------------------------------------------------------
-- RESPAWN HANDLING
---------------------------------------------------------------------------
--- Reset a destroyed node that received S_EVENT_BIRTH (respawn via CTLD/triggers/etc).
function AEGIS:_RespawnNode(groupName, nodeType)
local grp = Group.getByName(groupName)
if not grp or not grp:isExist() then
self:_Log("Respawn failed (group gone): " .. groupName, true)
return
end
self:_Log("*** RESPAWN: " .. groupName .. " [" .. nodeType .. "]", true)
if nodeType == "sam" then
local sam = self.samSites[groupName]
if not sam then return end
sam._respawnPending = nil
-- Reset all state fields
sam.state = nil -- nil forces _ApplyState to execute DCS commands
sam.emconGen = sam.emconGen + 1
sam.lastContactTime = 0
sam.sweepsSinceDetect = 0
sam.lastSweepHadContact = false
sam.spooked = false
sam.spookedUntil = 0
sam.harmCooldownUntil = 0
sam.harmEvents = {}
sam.harmMultiThreshold = math.random(self.harmMultiThresholdMin, self.harmMultiThresholdMax)
sam.harmReaction = nil
sam.harmReactionGen = sam.harmReactionGen + 1
sam.harmReactionPending = false
sam.harmWeapon = nil
sam.harmReactionStart = 0
sam.harmInbound = 0
sam.harmInboundExpiry = 0
sam.alertWithoutWezSince = 0
sam.alertFrustrationTimeout = 0
sam.frustrationCooldownUntil = 0
sam.jammed = false
sam.jammedEmconGen = sam.jammedEmconGen + 1
sam.jammedEmconActive = false
sam.hojUntil = 0
sam.hojCooldownUntil = 0
sam.hojPeekCount = 0
-- Re-cache position and reset mobile poll timing
local unit = grp:getUnit(1)
if unit then sam.pos = unit:getPoint() end
if sam.mobile then
sam.lastPosUpdate = 0
self:_RebuildMobileList()
end
-- Re-resolve tracking radar unit
if sam.sysData.trackRadar then
sam.trackRadarUnit = nil
local units = grp:getUnits()
if units then
for _, u in ipairs(units) do
if u:getTypeName() == sam.sysData.trackRadar then
sam.trackRadarUnit = u:getName()
break
end
end
end
end
-- Re-resolve command post unit
if sam.sysData.commandPost then
sam.commandPostUnit = nil
local units = grp:getUnits()
if units then
for _, u in ipairs(units) do
if u:getTypeName() == sam.sysData.commandPost then
sam.commandPostUnit = u:getName()
break
end
end
end
end
-- Silence and evaluate
grp:enableEmission(false)
grp:getController():setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
self:_SnapshotInitSAM(groupName, sam)
elseif nodeType == "ew" then
local ew = self.ewRadars[groupName]
if not ew then return end
ew._respawnPending = nil
ew.state = AEGIS.STATE.DARK
ew.hasContacts = false
ew.lastContact = 0
ew.contacts = {}
-- Re-cache position
local unit = grp:getUnit(1)
if unit then ew.pos = unit:getPoint() end
-- Check power
if not self:_NodeHasPower(ew) then
self:_Log(groupName .. ": respawn but power dead, permanently offline")
ew.state = AEGIS.STATE.DESTROYED
local ctrl = grp:getController()
ctrl:setOption(AI.Option.Ground.id.ALARM_STATE, AEGIS.ALARM.GREEN)
grp:enableEmission(false)
else
self:_UpgradeSectorForEW(ew.sector)
self:_RebuildPollOrder()
end
elseif nodeType == "pd" then
local pd = self.pdSites[groupName]
if not pd then return end
pd._respawnPending = nil
pd.state = nil -- nil forces _ApplyState
-- Re-cache position
local unit = grp:getUnit(1)
if unit then pd.pos = unit:getPoint() end
-- Silence emissions
grp:enableEmission(false)
grp:getController():setOption(AI.Option.Ground.id.ROE, AEGIS.ROE.WEAPON_HOLD)
-- Mirror parent state
if pd.parent then
local parentSam = self.samSites[pd.parent]
if parentSam and parentSam.state ~= AEGIS.STATE.DESTROYED then
if parentSam.state == AEGIS.STATE.ALERT or parentSam.state == AEGIS.STATE.EMCON_ENGAGED then
self:_ApplyState(groupName, "pd", AEGIS.STATE.ALERT)
else
self:_ApplyState(groupName, "pd", AEGIS.STATE.DARK)
end
else
self:_PromoteOrphanPD(groupName)
end
else
self:_ApplyState(groupName, "pd", AEGIS.STATE.DARK)
end
end
end
---------------------------------------------------------------------------
-- EW SECTOR UPGRADE + POLL ORDER REBUILD
---------------------------------------------------------------------------
--- When a new EW comes online in a sector, SAMs that were EMCON cycling
--- (because they had no EW) return to integrated DARK mode.
function AEGIS:_UpgradeSectorForEW(sectorName)
if not sectorName or sectorName == "_AUTO" then return end
local sec = self.sectors[sectorName]
if not sec then return end
local now = timer.getTime()
for _, samName in ipairs(sec.sams) do
local sam = self.samSites[samName]
if sam and sam.state ~= AEGIS.STATE.DESTROYED then
-- Don't touch SAMs in HARM cooldown
if sam.harmCooldownUntil > now then
self:_Log(" " .. samName .. ": HARM cooldown active, skipping EW upgrade")
-- Don't touch SAMs in jammed EMCON
elseif sam.jammedEmconActive then
self:_Log(" " .. samName .. ": jammed EMCON active, skipping EW upgrade")
-- Only upgrade SAMs currently in EMCON (autonomous cycling due to missing EW)
elseif sam.state == AEGIS.STATE.EMCON_ON or sam.state == AEGIS.STATE.EMCON_OFF then
self:_Log(" " .. samName .. ": EW restored in " .. sectorName .. ", returning to DARK")
self:_StopEMCON(samName)
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
end
end
end
end
--- Rebuild the sector poll order and recalculate sub-interval.
--- Called when EW nodes are added/removed mid-mission.
function AEGIS:_RebuildPollOrder()
self.sectorPollOrder = {}
for name, sector in pairs(self.sectors) do
if name ~= "_AUTO" and #sector.ew > 0 then
-- Only include sectors with at least one live EW
local hasLive = false
for _, ewName in ipairs(sector.ew) do
local ew = self.ewRadars[ewName]
if ew and ew.state ~= AEGIS.STATE.DESTROYED then
hasLive = true
break
end
end
if hasLive then
table.insert(self.sectorPollOrder, name)
end
end
end
table.sort(self.sectorPollOrder)
local numSectors = math.max(#self.sectorPollOrder, 1)
self.pollSubInterval = self.ewPollInterval / numSectors
-- Reset index to avoid out-of-bounds after rebuild
if self.sectorPollIndex > #self.sectorPollOrder then
self.sectorPollIndex = 0
end
self:_Log("Poll order rebuilt: " .. #self.sectorPollOrder .. " sectors, sub-interval "
.. string.format("%.1f", self.pollSubInterval) .. "s")
end
---------------------------------------------------------------------------
-- EA JAMMER HANDLERS (Phase 6)
---------------------------------------------------------------------------
--- S_EVENT_BIRTH: discover EA aircraft that spawned after mission start (client slots in MP)
function AEGIS:_OnBirthEA(event)
if not event.initiator then return end
local ok, unit = pcall(function() return event.initiator end)
if not ok or not unit then return end
-- Check if this unit belongs to the opposing coalition
local enemySide = (self.coalitionId == coalition.side.RED)
and coalition.side.BLUE or coalition.side.RED
local unitCoal = unit:getCoalition()
if unitCoal ~= enemySide then return end
-- Check if group name matches EA- pattern (or deprecated ECM-)
local grpOk, grp = pcall(function() return unit:getGroup() end)
if not grpOk or not grp then return end
local groupName = grp:getName()
local jamType = groupName:match("^EA%-([%w]+)%-")
if not jamType then
jamType = groupName:match("^ECM%-([%w]+)%-")
if jamType then
self:_Log("WARNING: " .. groupName .. " uses deprecated ECM- prefix, rename to EA-", true)
end
end
if not jamType then return end
-- Check if this is a player
local playerName = nil
local pOk, pName = pcall(function() return unit:getPlayerName() end)
if pOk and pName and pName ~= "" then playerName = pName end
-- Map unit ID for slot-based copilot/WSO lookup
local unitId = unit:getID()
if unitId then self.eaUnitMap[tostring(unitId)] = groupName end
-- Already registered? (AI groups found at _AutoDiscover, or player re-slotting)
if self.jammers[groupName] then
if playerName then
self.jammerPlayers[playerName] = groupName
local j = self.jammers[groupName]
if not j.playerControlled then
-- SP fix: _AutoDiscover registered as AI, but player just spawned in.
-- Upgrade to player-controlled: stop AI jamming, give player F10 menu + GUI.
j.playerControlled = true
j.active = false
j.mode = "OFF"
j.knownEmitters = {}
j.groupId = grp:getID()
local p3 = unit:getPosition()
if p3 then
j.pos = p3.p
j.heading = math.atan2(p3.x.z, p3.x.x)
else
j.pos = unit:getPoint()
end
self:_Log("*** EA AI->PLAYER UPGRADE: " .. playerName .. " in " .. groupName .. " — now player-controlled", true)
self:_CreateJammerF10Menu(groupName, j.groupId)
elseif j.playerControlled and not j.active then
-- Restore from despawn: alive + active + refresh position/groupId
j.alive = true
j.active = true
j.groupId = grp:getID()
j.knownEmitters = {}
local p3 = unit:getPosition()
if p3 then
j.pos = p3.p
j.heading = math.atan2(p3.x.z, p3.x.x)
else
j.pos = unit:getPoint()
end
self:_Log("*** EA PLAYER RE-JOINED: " .. playerName .. " in " .. groupName .. " — jammer reactivated", true)
self:_CreateJammerF10Menu(groupName, j.groupId)
else
-- Multicrew join or AI re-registration
if not j.alive then
j.alive = true
j.groupId = grp:getID()
end
self:_Log("*** EA PLAYER JOINED: " .. playerName .. " in " .. groupName .. " (crew)", true)
end
end
return
end
-- First time: register the jammer group
local isPlayer = (playerName ~= nil)
self:_RegisterJammer(groupName, jamType, isPlayer)
if isPlayer then
self.jammerPlayers[playerName] = groupName
self:_Log("*** EA PLAYER JOINED: " .. playerName .. " in " .. groupName, true)
self:_CreateJammerF10Menu(groupName, grp:getID())
end
end
--- S_EVENT_PLAYER_LEAVE_UNIT: remove player from tracking, deactivate when last crew leaves.
--- DCS fires this TWICE on slot change — second time initiator is nil (must guard).
function AEGIS:_OnPlayerLeaveEA(event)
if not event.initiator then return end -- nil-guard: second fire has nil initiator
local ok, grp = pcall(function() return event.initiator:getGroup() end)
if not ok or not grp then return end
local groupName = grp:getName()
local jammer = self.jammers[groupName]
if not jammer or not jammer.playerControlled then return end
-- Identify leaving player and remove from tracking
local pOk, pName = pcall(function() return event.initiator:getPlayerName() end)
if pOk and pName and self.jammerPlayers[pName] == groupName then
self.jammerPlayers[pName] = nil
end
-- Count remaining players in this group
local remaining = 0
for _, gn in pairs(self.jammerPlayers) do
if gn == groupName then remaining = remaining + 1 end
end
if remaining == 0 then
self:_Log("*** EA PLAYER LEFT: " .. groupName .. " — jammer deactivated (last crew out)", true)
jammer.active = false
else
self:_Log("*** EA CREW LEFT: " .. groupName .. " — " .. remaining .. " crew remaining", true)
end
end
--- Create per-group F10 menu for player-controlled EA aircraft.
--- Rebuilds entire menu tree on mode/pod change. Menu root is "EA".
---
--- Layout: modes-as-submenus so mode switch + target assignment is one menu session.
--- Fixed item counts (2 submenus + 3 commands) give stable F-key positions:
--- F1: HALF+DIR (submenu) F2: 2xDIR (submenu)
--- F3: FULL OMNI (command) F4: OFF (command) F5: STATUS (command)
function AEGIS:_CreateJammerF10Menu(groupName, groupId)
local j = self.jammers[groupName]
if not j then return end
-- Remove old menu tree if it exists
if j.menuRoot then
pcall(missionCommands.removeItemForGroup, groupId, j.menuRoot)
end
local aegis = self
local bl = self.jammerBaseline
local root = missionCommands.addSubMenuForGroup(groupId, "EA")
j.menuRoot = root
-- Helper: switch mode, set active flag, clear inapplicable pod assignments
local function switchMode(jj, modeKey, modeLabel)
jj.mode = modeKey
if modeKey == "OFF" then
jj.active = false
trigger.action.outTextForGroup(groupId, "EA OFF — jammer silent", 8)
aegis:_Log(groupName .. ": EA OFF by player", true)
else
jj.active = true
trigger.action.outTextForGroup(groupId, "EA MODE: " .. modeLabel, 8)
aegis:_Log(groupName .. ": EA mode -> " .. modeKey, true)
end
if modeKey == "OMNI" or modeKey == "OFF" then
jj.pod1Target = nil
jj.pod2Target = nil
jj.bearingLocked = false
elseif modeKey == "WIDE" then
jj.pod1Target = nil -- pod 1 is omni in this mode
end
end
-- ── SUBMENUS (DCS renders these first → stable F1/F2) ──
-- F1: WIDE — submenu with bearing lock + Pod 2 target list
local wideStar = (j.mode == "WIDE") and " *" or ""
local wideMenu = missionCommands.addSubMenuForGroup(groupId, "WIDE" .. wideStar, root)
-- Bearing lock/unlock (always first inside WIDE)
if j.bearingLocked then
local brgStr = string.format("%03d", math.floor(math.deg(j.lockedBearing) + 0.5) % 360)
missionCommands.addCommandForGroup(groupId, "UNLOCK BRG " .. brgStr, wideMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
if jj.mode ~= "WIDE" then switchMode(jj, "WIDE", "WIDE") end
jj.bearingLocked = false
trigger.action.outTextForGroup(groupId, "Pod 1: following aircraft heading", 5)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
else
missionCommands.addCommandForGroup(groupId, "LOCK BRG", wideMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
if jj.mode ~= "WIDE" then switchMode(jj, "WIDE", "WIDE") end
jj.bearingLocked = true
jj.lockedBearing = jj.heading
local brg = math.floor(math.deg(jj.heading) + 0.5) % 360
trigger.action.outTextForGroup(groupId, "Pod 1: bearing LOCKED at " .. brg, 5)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
end
-- SET BRG submenu: pick an arbitrary bearing (30° granularity) in REL or ABS mode.
-- GUI parity — GUI exposes 1° precision via text input; F10 can't accept typed input,
-- so we offer 12 preset headings (cardinal + intercardinal halves). Users who need
-- finer resolution can use the GUI overlay.
local brgMode = j.brgInputMode or "REL"
local calibrated = (j.magDeclination ~= nil)
local setBrgMenu = missionCommands.addSubMenuForGroup(groupId, "SET BRG", wideMenu)
-- Mode toggle at top of submenu — only functional once magnetic is calibrated
if calibrated then
missionCommands.addCommandForGroup(groupId, "Mode: " .. brgMode .. " (toggle)", setBrgMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
jj.brgInputMode = (jj.brgInputMode == "ABS") and "REL" or "ABS"
trigger.action.outTextForGroup(groupId, "BRG input mode: " .. jj.brgInputMode, 5)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
else
missionCommands.addCommandForGroup(groupId, "Mode: REL (CAL MAG for ABS)", setBrgMenu, function()
trigger.action.outTextForGroup(groupId,
"ABS mode needs calibration — use CAL MAG in the WIDE menu first", 7)
end)
end
-- Bearing presets, 30° spacing (000° ... 330°)
for brgDeg = 0, 330, 30 do
local label = string.format("%03d", brgDeg)
missionCommands.addCommandForGroup(groupId, label, setBrgMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
if jj.mode ~= "WIDE" then switchMode(jj, "WIDE", "WIDE") end
jj.bearingLocked = true
local mode = jj.brgInputMode or "REL"
if mode == "ABS" and jj.magDeclination then
-- Magnetic input → true: add declination
jj.lockedBearing = math.rad(brgDeg + jj.magDeclination)
else
-- Relative input → true: add current heading
jj.lockedBearing = jj.heading + math.rad(brgDeg)
end
while jj.lockedBearing >= 2 * math.pi do jj.lockedBearing = jj.lockedBearing - 2 * math.pi end
while jj.lockedBearing < 0 do jj.lockedBearing = jj.lockedBearing + 2 * math.pi end
local trueDeg = math.floor(math.deg(jj.lockedBearing) + 0.5) % 360
trigger.action.outTextForGroup(groupId,
string.format("Pod 1: BRG %s %03d° -> TRUE %03d°", mode, brgDeg, trueDeg), 5)
aegis:_Log(groupName .. ": BRG set " .. mode .. " " .. brgDeg .. "° (F10)", true)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
end
-- CAL MAG: capture current heading as magnetic declination (same as GUI CALIBRATE).
-- State is shared with the GUI — next REQ poll lights up the GUI's ABS toggle.
local calLabel = calibrated
and string.format("CAL MAG (%03d°)", j.magDeclination)
or "CAL MAG"
missionCommands.addCommandForGroup(groupId, calLabel, wideMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
local hdgDeg = math.floor(math.deg(jj.heading) + 0.5) % 360
jj.magDeclination = hdgDeg
trigger.action.outTextForGroup(groupId, "MAG CAL: declination = " .. hdgDeg .. "°", 6)
aegis:_Log(groupName .. ": MAG calibrated (declination=" .. hdgDeg .. "°) (F10)", true)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
-- Pod 2 target list (each entry switches to WIDE + assigns Pod 2)
self:_BuildModeTargetMenu(groupName, groupId, wideMenu, "WIDE", "WIDE", 2)
-- WIDE presets (selectable beam widths)
for _, preset in ipairs(AEGIS.WIDE_PRESETS) do
local star = (j.widePreset == preset.label) and " *" or ""
missionCommands.addCommandForGroup(groupId, preset.label .. star, wideMenu, function()
local jj = aegis.jammers[groupName]
if jj then
jj.wideGain = preset.gain
jj.wideHalfAngleRad = preset.angleRad
jj.widePieHalfRad = preset.pieHalfWidthRad
jj.widePreset = preset.label
aegis:_CreateJammerF10Menu(groupName, groupId)
aegis:_RefreshJammerStatus(groupName)
end
end)
end
-- Pod 2 unassign (if assigned)
if j.pod2Target then
missionCommands.addCommandForGroup(groupId, "UNASSIGN P2", wideMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
jj.pod2Target = nil
trigger.action.outTextForGroup(groupId, "Pod 2: unassigned", 5)
aegis:_Log(groupName .. ": pod2 unassigned", true)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
end
-- F2: 2xDIR — submenu with Pod 1 and Pod 2 sub-submenus
local dirStar = (j.mode == "DIR2") and " *" or ""
local dirMenu = missionCommands.addSubMenuForGroup(groupId, "2xDIR" .. dirStar, root)
local p1Label = j.pod1Target and ("Pod 1 -> " .. j.pod1Target) or "Pod 1 (select)"
local p1Menu = missionCommands.addSubMenuForGroup(groupId, p1Label, dirMenu)
self:_BuildModeTargetMenu(groupName, groupId, p1Menu, "DIR2", "2x DIR", 1)
if j.pod1Target then
missionCommands.addCommandForGroup(groupId, "UNASSIGN", p1Menu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
jj.pod1Target = nil
trigger.action.outTextForGroup(groupId, "Pod 1: unassigned", 5)
aegis:_Log(groupName .. ": pod1 unassigned", true)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
end
local p2Label = j.pod2Target and ("Pod 2 -> " .. j.pod2Target) or "Pod 2 (select)"
local p2Menu = missionCommands.addSubMenuForGroup(groupId, p2Label, dirMenu)
self:_BuildModeTargetMenu(groupName, groupId, p2Menu, "DIR2", "2x DIR", 2)
if j.pod2Target then
missionCommands.addCommandForGroup(groupId, "UNASSIGN", p2Menu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
jj.pod2Target = nil
trigger.action.outTextForGroup(groupId, "Pod 2: unassigned", 5)
aegis:_Log(groupName .. ": pod2 unassigned", true)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
end
-- ── COMMANDS (DCS renders these after submenus → stable F3/F4/F5) ──
-- F3: FULL OMNI
local omniStar = (j.mode == "OMNI") and " *" or ""
missionCommands.addCommandForGroup(groupId, "FULL OMNI" .. omniStar, root, function()
local jj = aegis.jammers[groupName]
if not jj then return end
switchMode(jj, "OMNI", "FULL OMNI")
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
-- F4: OFF
local offStar = (j.mode == "OFF") and " *" or ""
missionCommands.addCommandForGroup(groupId, "OFF" .. offStar, root, function()
local jj = aegis.jammers[groupName]
if not jj then return end
switchMode(jj, "OFF", "OFF")
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
-- F5: STATUS toggle
local statusLabel = j.statusActive and "STATUS: HIDE" or "STATUS: SHOW"
missionCommands.addCommandForGroup(groupId, statusLabel, root, function()
local jj = aegis.jammers[groupName]
if not jj then return end
jj.statusActive = not jj.statusActive
if jj.statusActive then
aegis:_RefreshJammerStatus(groupName)
else
trigger.action.outTextForGroup(groupId, "", 1, true)
end
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
-- Refresh persistent status display if active
self:_RefreshJammerStatus(groupName)
-- Schedule periodic menu refresh (30s) if not already scheduled
if not j.menuRefreshScheduled then
j.menuRefreshScheduled = true
local function periodicRefresh()
local jj = aegis.jammers[groupName]
if not jj or not jj.alive or not jj.playerControlled then return nil end
jj.menuRefreshScheduled = false
aegis:_CreateJammerF10Menu(groupName, groupId)
return nil
end
timer.scheduleFunction(periodicRefresh, nil, timer.getTime() + 30)
end
end
--- Signal strength bars lookup (index 1-5).
AEGIS.STRENGTH_BARS = { "|....", "||...", "|||..", "||||.", "|||||" }
--- Format ESM range estimate based on confidence level and true distance.
--- Returns short string for display: "" (none), "40-80" (low), "40-60" (med), "~48" (high).
function AEGIS._FormatESMRange(distNM, rangeConf, stale)
if not rangeConf or rangeConf < 1 then return "" end
local d = math.floor(distNM + 0.5)
local suffix = stale and "?" or ""
if rangeConf == 1 then
-- LOW: round to nearest 40, show ±20 band
local center = math.floor(d / 40 + 0.5) * 40
if center < 20 then center = 20 end
return (center - 20) .. "-" .. (center + 20) .. suffix
elseif rangeConf == 2 then
-- MED: round to nearest 20, show ±10 band
local center = math.floor(d / 20 + 0.5) * 20
if center < 10 then center = 10 end
return (center - 10) .. "-" .. (center + 10) .. suffix
else
-- HIGH: round to nearest 5
local rounded = math.floor(d / 5 + 0.5) * 5
if rounded < 5 then rounded = 5 end
return "~" .. rounded .. suffix
end
end
--- Compute emitter signal strength (1-5 bars) from distance.
--- Uses actRange (SAMs) or detRange/200NM (EWRs) as reference range.
function AEGIS:_EmitterStrength(distNM, refRangeNM)
if refRangeNM <= 0 then refRangeNM = 200 end
local ratio = math.min(distNM / refRangeNM, 1.0)
return 5 - math.floor(ratio * 4) -- 5=closest, 1=edge
end
--- Build emitter display label based on eaDebugLabels config.
--- Generic mode: NATO reporting name from srLabel (e.g., "Fan Song", "Big Bird"),
--- falls back to "SAM" for systems without srLabel (PD/SHORAD).
function AEGIS:_EmitterLabel(groupName, sysType, isEW)
if self.eaDebugLabels then
if isEW then
return groupName .. " [EWR]"
else
return groupName .. " [" .. (sysType or "?") .. "]"
end
else
if isEW then return "EW" end
local sysData = sysType and AEGIS.SYSTEM_DB[sysType]
return (sysData and sysData.srLabel) or "SAM"
end
end
--- Resolve a pod target group name to its display label.
function AEGIS:_PodTargetLabel(targetName)
local sam = self.samSites[targetName]
if sam then return self:_EmitterLabel(targetName, sam.sysType, false) end
local ew = self.ewRadars[targetName]
if ew then return self:_EmitterLabel(targetName, nil, true) end
return targetName -- fallback (shouldn't happen)
end
--- Return relative bearing (degrees, 0-359) from jammer to a pod target, or nil.
function AEGIS:_PodTargetRelBrg(jammerPos, jammerHdgDeg, targetName)
local tPos
local sam = self.samSites[targetName]
if sam and sam.pos then tPos = sam.pos
else
local ew = self.ewRadars[targetName]
if ew and ew.pos then tPos = ew.pos end
end
if not tPos then return nil end
local absBrg = math.deg(math.atan2(tPos.z - jammerPos.z, tPos.x - jammerPos.x))
absBrg = math.floor(absBrg + 0.5) % 360
return (absBrg - jammerHdgDeg + 360) % 360
end
--- Refresh persistent EA status display for a player jammer.
--- Shows mode, pod assignments, and known emitters with label + strength + relative bearing.
--- Called on emitter changes, mode changes, pod changes, and periodic refresh.
function AEGIS:_RefreshJammerStatus(jamName)
local j = self.jammers[jamName]
if not j or not j.statusActive or not j.playerControlled or not j.groupId then return end
if not j.pos then return end
local modeLabels = { OMNI = "FULL OMNI", WIDE = "WIDE", DIR2 = "2x DIR", OFF = "OFF" }
local modeLabel = modeLabels[j.mode] or j.mode
local lines = { "--- EA STATUS ---" }
table.insert(lines, "Mode: " .. modeLabel)
local hdgDeg = math.floor(math.deg(j.heading) + 0.5) % 360
if j.mode == "WIDE" then
if j.bearingLocked then
table.insert(lines, "Pod 1: OMNI BRG " .. string.format("%03d", math.floor(math.deg(j.lockedBearing) + 0.5) % 360))
else
table.insert(lines, "Pod 1: OMNI HDG " .. string.format("%03d", hdgDeg))
end
local p2lbl = j.pod2Target and self:_PodTargetLabel(j.pod2Target) or nil
local p2brg = j.pod2Target and self:_PodTargetRelBrg(j.pos, hdgDeg, j.pod2Target) or nil
table.insert(lines, "Pod 2: " .. (p2lbl and ("DIR -> " .. p2lbl .. string.format(" REL %03d", p2brg or 0)) or "DIR (unassigned)"))
elseif j.mode == "DIR2" then
local p1lbl = j.pod1Target and self:_PodTargetLabel(j.pod1Target) or nil
local p1brg = j.pod1Target and self:_PodTargetRelBrg(j.pos, hdgDeg, j.pod1Target) or nil
local p2lbl = j.pod2Target and self:_PodTargetLabel(j.pod2Target) or nil
local p2brg = j.pod2Target and self:_PodTargetRelBrg(j.pos, hdgDeg, j.pod2Target) or nil
table.insert(lines, "Pod 1: " .. (p1lbl and ("DIR -> " .. p1lbl .. string.format(" REL %03d", p1brg or 0)) or "DIR (unassigned)"))
table.insert(lines, "Pod 2: " .. (p2lbl and ("DIR -> " .. p2lbl .. string.format(" REL %03d", p2brg or 0)) or "DIR (unassigned)"))
end
-- Emitter list: label + strength + relative bearing, sorted by relative bearing
if j.knownEmitters then
local emitters = {}
for eName, eData in pairs(j.knownEmitters) do
local ePos, refRange = nil, 200
local sam = self.samSites[eName]
if sam and sam.pos then
ePos = sam.pos
refRange = sam.sysData and sam.sysData.actRange or 30
else
local ew = self.ewRadars[eName]
if ew and ew.pos then
ePos = ew.pos
refRange = (ew.detRange > 0) and ew.detRange or 200
end
end
if ePos then
local absBrg = math.deg(math.atan2(ePos.z - j.pos.z, ePos.x - j.pos.x))
absBrg = math.floor(absBrg + 0.5) % 360
local relBrg = (absBrg - hdgDeg + 360) % 360
local distNM = math.sqrt(eData.distSq) / AEGIS.NM_TO_M
local str = self:_EmitterStrength(distNM, refRange)
local lbl = self:_EmitterLabel(eName, eData.sysType, eData.isEW)
local rangeStr = AEGIS._FormatESMRange(distNM, eData.rangeConf, eData.stale)
table.insert(emitters, { label = lbl, strength = str, relBrg = relBrg, rangeStr = rangeStr })
end
end
table.sort(emitters, function(a, b) return a.relBrg < b.relBrg end)
if #emitters > 0 then
table.insert(lines, "-- EMITTERS --")
for _, e in ipairs(emitters) do
local rng = e.rangeStr ~= "" and (" " .. e.rangeStr) or ""
table.insert(lines, string.format(" %s %s REL %03d", e.label, AEGIS.STRENGTH_BARS[e.strength], e.relBrg) .. rng)
end
else
table.insert(lines, "-- NO EMITTERS --")
end
end
-- Display with generous duration — periodic refresh or emitter change will update
trigger.action.outTextForGroup(j.groupId, table.concat(lines, "\n"), 35, true)
end
--- Build emitter target list inside a mode submenu. Each target command switches
--- to the specified mode AND assigns the pod in one click. Sorted by bearing, capped at 10.
function AEGIS:_BuildModeTargetMenu(groupName, groupId, parentMenu, modeKey, modeLabel, podNum)
local j = self.jammers[groupName]
if not j or not j.pos then return end
local aegis = self
local bl = self.jammerBaseline
local maxRange = bl.effectRange * bl.directionalRangeMult
local maxRangeM = maxRange * AEGIS.NM_TO_M
local maxRangeSq = maxRangeM * maxRangeM
-- Gather radiating emitters in range
local hdgDeg = math.floor(math.deg(j.heading) + 0.5) % 360
local emitters = {}
for samName, sam in pairs(self.samSites) do
if self:_IsEmitting(sam) and sam.pos then
local dx = j.pos.x - sam.pos.x
local dz = j.pos.z - sam.pos.z
local distSq = dx*dx + dz*dz
if distSq <= maxRangeSq then
local absBrg = math.deg(math.atan2(sam.pos.z - j.pos.z, sam.pos.x - j.pos.x))
absBrg = math.floor(absBrg + 0.5) % 360
local relBrg = (absBrg - hdgDeg + 360) % 360
local distNM = math.sqrt(distSq) / AEGIS.NM_TO_M
local refRange = sam.sysData and sam.sysData.actRange or 30
local str = self:_EmitterStrength(distNM, refRange)
local lbl = self:_EmitterLabel(samName, sam.sysType, false)
table.insert(emitters, { name = samName, relBrg = relBrg, label = lbl, strength = str })
end
end
end
for ewName, ew in pairs(self.ewRadars) do
if ew.state ~= AEGIS.STATE.DESTROYED and ew.pos then
local dx = j.pos.x - ew.pos.x
local dz = j.pos.z - ew.pos.z
local distSq = dx*dx + dz*dz
if distSq <= maxRangeSq then
local absBrg = math.deg(math.atan2(ew.pos.z - j.pos.z, ew.pos.x - j.pos.x))
absBrg = math.floor(absBrg + 0.5) % 360
local relBrg = (absBrg - hdgDeg + 360) % 360
local distNM = math.sqrt(distSq) / AEGIS.NM_TO_M
local refRange = (ew.detRange > 0) and ew.detRange or 200
local str = self:_EmitterStrength(distNM, refRange)
local lbl = self:_EmitterLabel(ewName, nil, true)
table.insert(emitters, { name = ewName, relBrg = relBrg, label = lbl, strength = str })
end
end
end
-- Sort by relative bearing, cap at 10
table.sort(emitters, function(a, b) return a.relBrg < b.relBrg end)
local cap = math.min(#emitters, 10)
for i = 1, cap do
local e = emitters[i]
local label = string.format("%s %s REL %03d", e.label, AEGIS.STRENGTH_BARS[e.strength], e.relBrg)
missionCommands.addCommandForGroup(groupId, label, parentMenu, function()
local jj = aegis.jammers[groupName]
if not jj then return end
-- Switch mode if not already in it
if jj.mode ~= modeKey then
jj.mode = modeKey
jj.active = true
if modeKey == "WIDE" then jj.pod1Target = nil end
aegis:_Log(groupName .. ": EA mode -> " .. modeKey, true)
end
-- Assign pod (clear other pod if it had the same target — swap, not duplicate)
if podNum == 1 then
if jj.pod2Target == e.name then jj.pod2Target = nil end
jj.pod1Target = e.name
else
if jj.pod1Target == e.name then jj.pod1Target = nil end
jj.pod2Target = e.name
end
trigger.action.outTextForGroup(groupId,
"EA: " .. modeLabel .. " — Pod " .. podNum .. " -> " .. e.label
.. string.format(" REL %03d", e.relBrg), 5)
aegis:_Log(groupName .. ": pod" .. podNum .. " -> " .. e.name, true)
aegis:_CreateJammerF10Menu(groupName, groupId)
end)
end
end
---------------------------------------------------------------------------
-- EA JAMMER EVALUATION (Phase 6 core logic)
---------------------------------------------------------------------------
--- Refresh jammer positions and headings (aircraft move). Called once per full EW poll rotation.
--- Also handles stale pod-target cleanup and emitter alerts for player jammers.
function AEGIS:_UpdateJammerPositions()
for jamName, j in pairs(self.jammers) do
if j.alive then
local ok, grp = pcall(function() return Group.getByName(jamName) end)
if ok and grp and grp:isExist() then
local unit = grp:getUnit(1)
if unit then
local p3 = unit:getPosition()
if p3 then
j.pos = p3.p
j.heading = math.atan2(p3.x.z, p3.x.x)
else
j.pos = unit:getPoint()
end
end
else
j.alive = false
j.active = false
end
-- Pod assignments are fully persistent — WSO manages via F10 UNASSIGN.
-- Dead targets are harmless (pod points at nothing), and the WSO knows
-- from EMITTER LOST alerts or the persistent status display.
end
end
end
--- Scan emitter changes for player jammers. Runs every sub-cycle (~1.4s with
--- 7 sectors) so the WSO gets near-real-time NEW EMITTER / EMITTER LOST alerts.
--- Separated from _UpdateJammerPositions (which runs per full rotation) because
--- emitter scanning is pure table lookups — no DCS API calls.
function AEGIS:_ScanJammerEmitters()
local bl = self.jammerBaseline
local maxRange = bl.effectRange * bl.directionalRangeMult
local maxRangeM = maxRange * AEGIS.NM_TO_M
local maxRangeSq = maxRangeM * maxRangeM
for jamName, j in pairs(self.jammers) do
if j.alive and j.playerControlled and j.groupId and j.pos then
local currentEmitters = {}
for samName2, sam2 in pairs(self.samSites) do
if self:_IsEmitting(sam2) and sam2.pos then
local dx = j.pos.x - sam2.pos.x
local dz = j.pos.z - sam2.pos.z
local d2 = dx*dx + dz*dz
if d2 <= maxRangeSq then
currentEmitters[samName2] = { distSq = d2, isEW = false, sysType = sam2.sysType }
end
end
end
for ewName2, ew2 in pairs(self.ewRadars) do
if ew2.state ~= AEGIS.STATE.DESTROYED and ew2.pos then
local dx = j.pos.x - ew2.pos.x
local dz = j.pos.z - ew2.pos.z
local d2 = dx*dx + dz*dz
if d2 <= maxRangeSq then
currentEmitters[ewName2] = { distSq = d2, isEW = true }
end
end
end
local anyChange = false
local now = timer.getTime()
-- Merge current emitters into known, mark as live
for eName, eData in pairs(currentEmitters) do
local prev = j.knownEmitters[eName]
if not prev then
if not j.statusActive then
local lbl = self:_EmitterLabel(eName, eData.sysType, eData.isEW)
trigger.action.outTextForGroup(j.groupId, "NEW EMITTER: " .. lbl, 5)
end
anyChange = true
elseif prev.stale then
anyChange = true -- returning from stale
end
eData.lastSeen = now
eData.stale = false
-- Carry forward range confidence from previous entry
local conf = prev and prev.rangeConf or 0
-- Roll for range reveal advance (0=NONE, 1=LOW, 2=MED, 3=HIGH)
if conf < 3 then
local distNM = math.sqrt(eData.distSq) / AEGIS.NM_TO_M
if distNM < 0.1 then distNM = 0.1 end
local chance = bl.esmRevealChance * math.min(1.0, bl.esmRevealRefDist / distNM)
if math.random() < chance then
conf = conf + 1
end
end
eData.rangeConf = conf
j.knownEmitters[eName] = eData
end
-- Mark emitters not in current as stale, drop expired or destroyed
for eName, eData in pairs(j.knownEmitters) do
if not currentEmitters[eName] then
-- Check if destroyed — drop immediately
local sam = self.samSites[eName]
local ew = self.ewRadars[eName]
if (sam and sam.state == AEGIS.STATE.DESTROYED) or (ew and ew.state == AEGIS.STATE.DESTROYED) then
j.knownEmitters[eName] = nil
anyChange = true
elseif self.eaEmitterMemory > 0 then
if not eData.stale then
eData.stale = true
eData.lastSeen = eData.lastSeen or now
if not j.statusActive then
local lbl = self:_EmitterLabel(eName, eData.sysType, eData.isEW)
trigger.action.outTextForGroup(j.groupId, "EMITTER LOST: " .. lbl, 5)
end
anyChange = true
end
-- Decay range confidence while stale: one level per decayInterval
if eData.rangeConf and eData.rangeConf > 0 then
local staleTime = now - eData.lastSeen
local expectedConf = 3 - math.floor(staleTime / bl.esmDecayInterval)
if expectedConf < 0 then expectedConf = 0 end
if eData.rangeConf > expectedConf then
eData.rangeConf = expectedConf
end
end
if now - eData.lastSeen > self.eaEmitterMemory then
j.knownEmitters[eName] = nil -- expired
anyChange = true
end
else
-- No memory, drop immediately (legacy behavior)
if not j.statusActive then
local lbl = self:_EmitterLabel(eName, eData.sysType, eData.isEW)
trigger.action.outTextForGroup(j.groupId, "EMITTER LOST: " .. lbl, 5)
end
j.knownEmitters[eName] = nil
anyChange = true
end
end
end
if anyChange then
self:_ScheduleMenuRefresh(jamName)
self:_RefreshJammerStatus(jamName)
end
end
end
end
--- Schedule a deferred F10 menu rebuild for a player jammer.
--- Coalesces multiple requests within the same poll cycle.
function AEGIS:_ScheduleMenuRefresh(jamName)
local j = self.jammers[jamName]
if not j or not j.playerControlled or j.menuRefreshScheduled then return end
j.menuRefreshScheduled = true
local aegis = self
timer.scheduleFunction(function()
local jj = aegis.jammers[jamName]
if jj and jj.alive and jj.playerControlled and jj.groupId then
aegis:_CreateJammerF10Menu(jamName, jj.groupId)
end
if jj then jj.menuRefreshScheduled = false end
return nil
end, nil, timer.getTime() + 0.5)
end
--- Compute the jam effects on a specific EW from all active jammers.
--- Returns a list of effect records, each describing one jammer's influence:
--- { bearingToJammer, isOmni, burnThroughNM, pieHalfWidth }
--- Also returns jamBearing (radians) of strongest jammer.
--- EW burn-through uses physics-based β formula: BT = β / (gainMult × mult) × √dist
function AEGIS:_GetEWJamState(ew)
if not ew.pos then return {}, 0 end
local bl = self.jammerBaseline
local ewDetNM = (ew.detRange > 0) and ew.detRange or 150 -- stock EW range if no cap
local effects = {}
local strongestBearing = 0
local strongestBT = math.huge
for _, j in pairs(self.jammers) do
if j.alive and j.active and j.pos then
-- Bearing and distance from EW to jammer
local dx = j.pos.x - ew.pos.x
local dz = j.pos.z - ew.pos.z
local bearingToJammer = math.atan2(dz, dx)
local distNM = math.sqrt(dx*dx + dz*dz) / AEGIS.NM_TO_M
if distNM < 0.1 then distNM = 0.1 end
local sqrtDist = math.sqrt(distNM)
-- Check each jam component this jammer produces
-- OMNI: always produces an omni effect on all EWs
-- WIDE: cone pod sprays forward — check if EW is in cone
-- DIR2: no omni, only directional on selected targets
-- Range-dependent pie narrowing: full pie inside ewPieRefDist, shrinks linearly beyond
local pieFraction = math.min(1.0, bl.ewPieRefDist / distNM)
local omniEffect = nil
local dirEffect = nil
if j.mode == "OMNI" then
-- Full omni: both pods — BT = β / (omniGain × mult) × √dist
local bt = bl.ewBeta / (bl.omniGain * j.mult) * sqrtDist
if bt < ewDetNM then
omniEffect = {
bearingToJammer = bearingToJammer,
isOmni = true,
burnThroughNM = bt,
pieHalfWidth = bl.omniPieHalfWidth * pieFraction,
}
end
elseif j.mode == "WIDE" then
-- Wide cone: check if EW is inside configurable cone
local bearingFromJammer = math.atan2(ew.pos.z - j.pos.z, ew.pos.x - j.pos.x)
local refBearing = (j.bearingLocked and j.lockedBearing) or j.heading
local offset = bearingFromJammer - refBearing
while offset > math.pi do offset = offset - 2 * math.pi end
while offset < -math.pi do offset = offset + 2 * math.pi end
local coneHalf = j.wideHalfAngleRad or bl.wideHalfAngleRad
if math.abs(offset) <= coneHalf then
local wideGain = j.wideGain or bl.wideGain
local bt = bl.ewBeta / (wideGain * j.mult) * sqrtDist
if bt < ewDetNM then
-- Pie width is power-based (more focused = wider shadow), not cone-based
local widePie = j.widePieHalfRad or bl.widePieHalfRad
omniEffect = {
bearingToJammer = bearingToJammer,
isOmni = true,
burnThroughNM = bt,
pieHalfWidth = widePie * pieFraction,
}
end
end
-- Directional pod: only affects the selected target EW
if j.pod2Target == ew.name then
local bt = bl.ewBeta / (bl.dirGain * j.mult) * sqrtDist
if bt < ewDetNM then
dirEffect = {
bearingToJammer = bearingToJammer,
isOmni = false,
burnThroughNM = bt,
pieHalfWidth = bl.directionalPieHalfWidth * pieFraction,
}
end
end
elseif j.mode == "DIR2" then
-- Both pods directional: only affect selected targets
if j.pod1Target == ew.name or j.pod2Target == ew.name then
local bt = bl.ewBeta / (bl.dirGain * j.mult) * sqrtDist
if bt < ewDetNM then
dirEffect = {
bearingToJammer = bearingToJammer,
isOmni = false,
burnThroughNM = bt,
pieHalfWidth = bl.directionalPieHalfWidth * pieFraction,
}
end
end
end
if omniEffect then
table.insert(effects, omniEffect)
if omniEffect.burnThroughNM < strongestBT then
strongestBT = omniEffect.burnThroughNM
strongestBearing = bearingToJammer
end
end
if dirEffect then
table.insert(effects, dirEffect)
if dirEffect.burnThroughNM < strongestBT then
strongestBT = dirEffect.burnThroughNM
strongestBearing = bearingToJammer
end
end
end
end
return effects, strongestBearing
end
--- Check if a specific contact is masked (jammed) from an EW's perspective.
--- Uses pie geometry + cosine gradient + burn-through distance comparison.
--- @param ew — EW radar node
--- @param contactPos — DCS position {x, y, z} of the contact
--- @param effects — list from _GetEWJamState()
--- @return true if the contact is jammed (should be filtered from the feed)
function AEGIS:_IsContactJammed(ew, contactPos, effects)
if not ew.pos or not contactPos or #effects == 0 then return false end
-- Bearing from EW to contact
local cdx = contactPos.x - ew.pos.x
local cdz = contactPos.z - ew.pos.z
local bearingToContact = math.atan2(cdz, cdx)
local contactDistM = math.sqrt(cdx * cdx + cdz * cdz)
local contactDistNM = contactDistM / AEGIS.NM_TO_M
for _, eff in ipairs(effects) do
-- Angular offset: how far is the contact from the jammer's bearing as seen by the EW?
local angOffset = bearingToContact - eff.bearingToJammer
-- Normalize to [-pi, pi]
while angOffset > math.pi do angOffset = angOffset - 2 * math.pi end
while angOffset < -math.pi do angOffset = angOffset + 2 * math.pi end
if math.abs(angOffset) <= eff.pieHalfWidth then
-- Contact is inside the pie — apply cosine gradient
-- At boresight (offset=0): full jam. At edge (offset=pieHalfWidth): zero jam.
local gradient = math.cos(angOffset * (math.pi / 2) / eff.pieHalfWidth)
-- Effective burn-through with gradient: wider offset = less jam = larger effective BT
-- gradient=1 at boresight: effectiveBT = burnThroughNM (full jam, contacts far from EW masked)
-- gradient=0 at edge: effectiveBT = infinity (no jam effect)
if gradient > 0.01 then
-- Effective jam range: contactDistNM < (ewDetRange - burnThroughNM) * gradient
-- Simpler: contact masked if its distance from EW exceeds burnThroughNM / gradient
local effectiveBT = eff.burnThroughNM / gradient
if contactDistNM > effectiveBT then
return true -- contact is beyond burn-through range, masked by jammer
end
end
end
end
return false -- contact outside all pies or inside burn-through range
end
--- Compute burn-through distance for a jammer affecting a SAM.
--- Returns burn-through range in NM if jammer has effect, or nil if out of range / no effect.
--- SAM-only: EW burn-through uses inline β formula in _GetEWJamState.
--- @param jammer — jammer node from self.jammers
--- @param targetPos — DCS position {x, y, z} of the SAM
--- @param refRange — NM: threshold for "is the SAM jammed?" (sam.wez)
--- @param burnRange — NM: fed into the formula (sam.wez * samTrackingBias)
--- @param gainMult — beam gain multiplier from _BeamGain()
function AEGIS:_ComputeBurnThrough(jammer, targetPos, refRange, burnRange, gainMult)
if not jammer.pos or not targetPos then return nil end
local bl = self.jammerBaseline
-- Gain-scaled range: tighter beam = more energy per steradian = longer reach
-- rangeMult scales linearly from 1.0 at omniGain, capped at directionalRangeMult
local rangeMult = 1.0 + (gainMult / bl.omniGain - 1.0) * bl.rangeGainScale
local maxRange = bl.effectRange * math.min(rangeMult, bl.directionalRangeMult)
local dx = targetPos.x - jammer.pos.x
local dz = targetPos.z - jammer.pos.z
local distNM = math.sqrt(dx * dx + dz * dz) / AEGIS.NM_TO_M
-- Hard AoE gate at gain-scaled maxRange
if distNM > maxRange then return nil end
if distNM < 0.1 then distNM = 0.1 end -- prevent div-by-zero
-- Spread factor: inverse of gain (higher gain = lower spread = deeper penetration)
local spreadFactor = 1.0 / gainMult
local burnThrough = burnRange * bl.burnThroughRatio
* spreadFactor
/ jammer.mult
* math.pow(distNM / maxRange, bl.burnExponent)
-- If burn-through >= refRange, jammer is too far/weak to have effect on this SAM
if burnThrough >= refRange then return nil end
return burnThrough
end
--- Check if a named SAM or EW is currently radiating (emitting radar energy).
--- Used for stale pod-target cleanup and emitter alert scanning.
function AEGIS:_IsTargetRadiating(groupName)
local sam = self.samSites[groupName]
if sam then return self:_IsEmitting(sam) end
local ew = self.ewRadars[groupName]
if ew then return ew.state ~= AEGIS.STATE.DESTROYED end
return false
end
--- Check if a SAM is being jammed by any active jammer.
--- Returns: jammed (bool), burnThroughNM (number or nil — closest burn-through distance)
--- Uses physics-based burn-through formula from JAMMER_BASELINE.
function AEGIS:_IsJammed(sam)
if not sam.pos then return false, nil end
-- Home-on-Jam: immunity window active — jammer has no effect on this SAM
if sam.hojUntil > 0 and timer.getTime() < sam.hojUntil then
return false, nil
end
local bl = self.jammerBaseline
local wez = sam.sysData.wez
local refRange = wez
local burnRange = wez * (sam.sysData.trackingBias or bl.samTrackingBias)
local bestBT = nil -- track tightest (smallest) burn-through across all jammers
for _, j in pairs(self.jammers) do
if j.alive and j.active and j.pos then
-- Determine if any pod is directional-targeting this SAM
local isDir = false
if j.mode == "DIR2" then
isDir = (j.pod1Target == sam.name) or (j.pod2Target == sam.name)
elseif j.mode == "WIDE" then
isDir = (j.pod2Target == sam.name)
end
if j.mode == "OMNI" then
local bt = self:_ComputeBurnThrough(j, sam.pos, refRange, burnRange, bl.omniGain)
if bt then
if not bestBT or bt < bestBT then bestBT = bt end
end
elseif j.mode == "WIDE" then
-- Cone pod only affects SAMs inside configurable cone
local bearingToSam = math.atan2(sam.pos.z - j.pos.z, sam.pos.x - j.pos.x)
local refBearing = (j.bearingLocked and j.lockedBearing) or j.heading
local offset = bearingToSam - refBearing
while offset > math.pi do offset = offset - 2 * math.pi end
while offset < -math.pi do offset = offset + 2 * math.pi end
local coneHalf = j.wideHalfAngleRad or bl.wideHalfAngleRad
if math.abs(offset) <= coneHalf then
local bt = self:_ComputeBurnThrough(j, sam.pos, refRange, burnRange, j.wideGain or bl.wideGain)
if bt then
if not bestBT or bt < bestBT then bestBT = bt end
end
end
end
if isDir then
local bt = self:_ComputeBurnThrough(j, sam.pos, refRange, burnRange, bl.dirGain)
if bt then
if not bestBT or bt < bestBT then bestBT = bt end
end
end
end
end
if bestBT then
if self.debug then
self:_Log(string.format("%s: JAMMED (WEZ %.0f, BT %.1f NM, jammed WEZ %.1f-%.0f NM)",
sam.name, wez, bestBT, bestBT, wez))
end
return true, bestBT
end
return false, nil
end
---------------------------------------------------------------------------
-- JAMMED EMCON CYCLING (EA effect)
-- When a jammer detects an emitting SAM, the crew shuts down to reduce
-- exposure and enters a separate EMCON cycle with different timing.
-- Uses jammedEmconGen for timer cancellation (independent of emconGen).
---------------------------------------------------------------------------
--- Get jammed EMCON timing for a SAM based on HOJ capability.
--- HOJ-capable SAMs peek aggressively; standard SAMs are cautious.
function AEGIS:_JammedEmconTiming(sam)
if sam.sysData.homeOnJam then
return self.jamEmconOnMinHOJ, self.jamEmconOnMaxHOJ,
self.jamEmconOffMinHOJ, self.jamEmconOffMaxHOJ
else
return self.jamEmconOnMinStd, self.jamEmconOnMaxStd,
self.jamEmconOffMinStd, self.jamEmconOffMaxStd
end
end
--- Start jammed EMCON cycling. SAM goes dark, then cycles briefly.
--- Called when EW poll detects a jammed emitting SAM.
function AEGIS:_StartJammedEMCON(samName)
local sam = self.samSites[samName]
if not sam then return end
-- Priority: HARM cooldown wins — don't override
if sam.harmCooldownUntil > timer.getTime() then return end
-- Already in jammed EMCON?
if sam.jammedEmconActive then return end
-- Kill normal EMCON timers to prevent conflicts
self:_StopEMCON(samName)
sam.jammedEmconGen = sam.jammedEmconGen + 1
sam.jammedEmconActive = true
sam.jammed = true
sam.hojPeekCount = 0
self:_Log(samName .. ": JAMMED — entering jammed EMCON cycling", true)
-- Crew shuts down
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
-- Schedule first on-phase after random off-duration
local _, _, offMin, offMax = self:_JammedEmconTiming(sam)
local offDuration = math.random(offMin, offMax)
local gen = sam.jammedEmconGen
local aegis = self
timer.scheduleFunction(function()
if sam.jammedEmconGen ~= gen then return nil end
aegis:_JammedEmconOnPhase(samName, gen)
return nil
end, nil, timer.getTime() + offDuration)
end
--- Stop jammed EMCON cycling. Cancels pending timers via generation counter.
function AEGIS:_StopJammedEMCON(samName)
local sam = self.samSites[samName]
if not sam then return end
sam.jammedEmconGen = sam.jammedEmconGen + 1 -- cancel pending timers
sam.jammedEmconActive = false
sam.jammed = false
sam.hojPeekCount = 0
self:_Log(samName .. ": leaving jammed EMCON")
end
--- Check if any radar-detected contact is inside the burn-through range.
--- Returns true if at least one contact is within burnThroughNM of the SAM.
function AEGIS:_HasBurnThroughContact(samName, burnThroughNM)
local sam = self.samSites[samName]
if not sam or not burnThroughNM or not sam.pos then return false end
local btM = burnThroughNM * AEGIS.NM_TO_M
local btSq = btM * btM
local grp = Group.getByName(samName)
if not grp or not grp:isExist() then return false end
local ctrl = grp:getController()
local detected = ctrl:getDetectedTargets(Controller.Detection.RADAR)
if not detected then return false end
for _, det in ipairs(detected) do
if det.object then
local ok, cpos = pcall(function() return det.object:getPoint() end)
if ok and cpos then
local dx = sam.pos.x - cpos.x
local dz = sam.pos.z - cpos.z
if (dx * dx + dz * dz) <= btSq then
return true
end
end
end
end
return false
end
--- Jammed EMCON on-phase: brief radar peek. Check if jammer is still present.
--- During the jam detection delay, burn-through contacts are engageable.
function AEGIS:_JammedEmconOnPhase(samName, gen)
local sam = self.samSites[samName]
if not sam or sam.jammedEmconGen ~= gen then return end
if sam.state == AEGIS.STATE.DESTROYED then return end
-- HARM cooldown takes priority
if sam.harmCooldownUntil > timer.getTime() then
-- HARM reaction owns the SAM; schedule retry after cooldown
local retryAt = sam.harmCooldownUntil + math.random(5, 15)
local aegis = self
timer.scheduleFunction(function()
if sam.jammedEmconGen ~= gen then return nil end
aegis:_JammedEmconOnPhase(samName, gen)
return nil
end, nil, retryAt)
return
end
-- Radar on (brief peek)
self:_ApplyState(samName, "sam", AEGIS.STATE.EMCON_OFF)
-- PB HARM check: SAM just turned on — if harmInbound active, HARM takes over
if self:_TriggerHarmInboundReaction(samName) then
self:_StopJammedEMCON(samName)
return
end
-- Two-phase peek:
-- detectDelay (1-3s): crew identifies jammer, check for immediate burn-through
-- onDuration (8-15s): full peek expires, final check, then go dark
local detectDelay = math.random(self.jamDetectionDelayMin, self.jamDetectionDelayMax)
local onMin, onMax = self:_JammedEmconTiming(sam)
local onDuration = math.random(onMin, onMax)
local aegis = self
timer.scheduleFunction(function()
if sam.jammedEmconGen ~= gen then return nil end
if sam.state == AEGIS.STATE.DESTROYED then return nil end
-- Is jammer still there?
local stillJammed, burnThroughNM = aegis:_IsJammed(sam)
if not stillJammed then
-- Jammer left — exit jammed EMCON, poll will restore normal state
aegis:_StopJammedEMCON(samName)
aegis:_Log(samName .. ": jammer gone, exiting jammed EMCON")
return nil
end
-- Home-on-Jam roll: HOJ-capable SAM tries to see through the jamming
if aegis.hojEnabled and sam.sysData.homeOnJam then
local hojRange = sam.sysData.actRange
local hojRangeM = hojRange * AEGIS.NM_TO_M
local hojRangeSq = hojRangeM * hojRangeM
-- Find nearest active jammer within actRange
local nearestSq = nil
for _, j in pairs(aegis.jammers) do
if j.alive and j.active and j.pos then
local dx = sam.pos.x - j.pos.x
local dz = sam.pos.z - j.pos.z
local dSq = dx * dx + dz * dz
if dSq <= hojRangeSq then
if not nearestSq or dSq < nearestSq then nearestSq = dSq end
end
end
end
if nearestSq and (sam.hojCooldownUntil <= timer.getTime()) then
sam.hojPeekCount = sam.hojPeekCount + 1
local chance = aegis.hojBasePct * sam.hojPeekCount
if math.random() < chance then
-- HOJ triggered! Jam suppression suspended.
local peekNum = sam.hojPeekCount
local window = aegis.hojWindowMin + math.random() * (aegis.hojWindowMax - aegis.hojWindowMin)
sam.hojUntil = timer.getTime() + window
sam.hojCooldownUntil = sam.hojUntil + aegis.hojCooldown
sam.hojPeekCount = 0
aegis:_StopJammedEMCON(samName)
aegis:_ApplyState(samName, "sam", AEGIS.STATE.ALERT)
aegis:_Log(string.format("%s: *** HOJ TRIGGERED (peek #%d, %.0f%%) — weapons free %.0fs",
samName, peekNum, chance * 100, window), true)
return nil -- exit timer chain
else
if aegis.debug then
aegis:_Log(string.format("%s: HOJ roll failed (peek #%d, %.0f%%)",
samName, sam.hojPeekCount, chance * 100))
end
end
end
end
-- Jammer caught us. Immediate burn-through check.
if aegis:_HasBurnThroughContact(samName, burnThroughNM) then
aegis:_Log(samName .. ": jammed EMCON burn-through — ALERT, engaging", true)
aegis:_ApplyState(samName, "sam", AEGIS.STATE.ALERT)
aegis:_JammedBurnThroughMonitor(samName, gen)
return nil
end
-- Jammed, no burn-through yet. Stay on for the rest of the peek window.
local remaining = onDuration - detectDelay
if remaining < 1 then remaining = 1 end
timer.scheduleFunction(function()
if sam.jammedEmconGen ~= gen then return nil end
if sam.state == AEGIS.STATE.DESTROYED then return nil end
-- End of peek window. Final checks.
local stillJammed2, bt2 = aegis:_IsJammed(sam)
if not stillJammed2 then
aegis:_StopJammedEMCON(samName)
aegis:_Log(samName .. ": jammer gone, exiting jammed EMCON")
return nil
end
if aegis:_HasBurnThroughContact(samName, bt2) then
aegis:_Log(samName .. ": jammed EMCON burn-through — ALERT, engaging", true)
aegis:_ApplyState(samName, "sam", AEGIS.STATE.ALERT)
aegis:_JammedBurnThroughMonitor(samName, gen)
return nil
end
-- Still jammed, no burn-through — go dark
aegis:_JammedEmconOffPhase(samName, gen)
return nil
end, nil, timer.getTime() + remaining)
return nil
end, nil, timer.getTime() + detectDelay)
end
--- Jammed EMCON off-phase: crew hides. Schedule next on-phase.
function AEGIS:_JammedEmconOffPhase(samName, gen)
local sam = self.samSites[samName]
if not sam or sam.jammedEmconGen ~= gen then return end
if sam.state == AEGIS.STATE.DESTROYED then return end
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
local _, _, offMin, offMax = self:_JammedEmconTiming(sam)
local offDuration = math.random(offMin, offMax)
local aegis = self
timer.scheduleFunction(function()
if sam.jammedEmconGen ~= gen then return nil end
aegis:_JammedEmconOnPhase(samName, gen)
return nil
end, nil, timer.getTime() + offDuration)
end
--- Monitor burn-through engagement. SAM stays ALERT as long as a contact
--- remains inside burn-through range. When contacts leave, back to off-phase.
--- If jammer leaves entirely, exits jammed EMCON.
function AEGIS:_JammedBurnThroughMonitor(samName, gen)
local sam = self.samSites[samName]
if not sam or sam.jammedEmconGen ~= gen then return end
local aegis = self
local checkInterval = 5
local function monitor()
if sam.jammedEmconGen ~= gen then return nil end
if sam.state == AEGIS.STATE.DESTROYED then return nil end
-- HARM reaction took over? Stop monitoring, HARM owns it.
if sam.harmCooldownUntil > timer.getTime() then return nil end
-- Is jammer still there?
local stillJammed, burnThroughNM = aegis:_IsJammed(sam)
if not stillJammed then
-- Jammer left — exit jammed EMCON entirely
aegis:_StopJammedEMCON(samName)
aegis:_Log(samName .. ": jammer gone during burn-through, exiting jammed EMCON")
return nil
end
-- Check burn-through: any contact still inside burn-through range?
local stillBurnThrough = aegis:_HasBurnThroughContact(samName, burnThroughNM)
if stillBurnThrough then
-- Contact still close — keep engaging
return timer.getTime() + checkInterval
else
-- Contact left burn-through range — crew goes dark
aegis:_Log(samName .. ": burn-through contact left, returning to jammed EMCON")
aegis:_JammedEmconOffPhase(samName, gen)
return nil
end
end
timer.scheduleFunction(monitor, nil, timer.getTime() + checkInterval)
end
---------------------------------------------------------------------------
-- HARM DETECTION (Phase 3) + REACTION POLICIES (Phase 3.2)
---------------------------------------------------------------------------
--- Returns true if a SAM is currently emitting (radar on).
function AEGIS:_IsEmitting(sam)
return sam.state == AEGIS.STATE.ALERT
or sam.state == AEGIS.STATE.EMCON_OFF
or sam.state == AEGIS.STATE.EMCON_ENGAGED
end
--- Check if a PB HARM inbound flag is active on this SAM.
--- Returns true if harmInbound was set and hasn't expired yet.
function AEGIS:_CheckHarmInbound(sam)
return sam.harmInbound > 0 and timer.getTime() <= sam.harmInboundExpiry
end
--- If harmInbound is active, trigger own-radar detection and HARM reaction.
--- Called when a SAM transitions to an emitting state. Returns true if reaction triggered.
function AEGIS:_TriggerHarmInboundReaction(samName)
local sam = self.samSites[samName]
if not sam then return false end
if not self:_CheckHarmInbound(sam) then return false end
local now = timer.getTime()
self:_Log(samName .. ": own-radar HARM detection (harmInbound active)", true)
-- Record for multi-HARM tracking
self:_RecordHARMEvent(sam, now)
-- Clear the flag (consumed)
sam.harmInbound = 0
sam.harmInboundExpiry = 0
-- Schedule reaction with crew delay (same as TOO/SP — classifying fast-closing contact)
local delay = math.random(self.harmReactionDelayMin, self.harmReactionDelayMax)
sam.harmReactionGen = sam.harmReactionGen + 1
local gen = sam.harmReactionGen
local aegis = self
-- Freeze state during crew processing — prevents frustration/poll from
-- overriding a pending HARM reaction
sam.harmCooldownUntil = now + delay
self:_Log(" " .. samName .. ": crew processing own-radar HARM (" .. delay .. "s)...", true)
timer.scheduleFunction(function()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
aegis:_ExecuteHARMReaction(samName)
return nil
end, nil, now + delay)
return true
end
--- Record a HARM event timestamp and prune entries outside the multi-HARM window.
function AEGIS:_RecordHARMEvent(sam, timestamp)
table.insert(sam.harmEvents, timestamp)
-- Prune old entries outside the window
local cutoff = timestamp - self.harmMultiWindow
local fresh = {}
for _, t in ipairs(sam.harmEvents) do
if t >= cutoff then
table.insert(fresh, t)
end
end
sam.harmEvents = fresh
end
--- Check if the HARM weapon targeting this SAM is still in flight.
--- Returns false if weapon ref is nil, stale, or destroyed.
function AEGIS:_HARMStillInFlight(sam)
if not sam.harmWeapon then return false end
local ok, exists = pcall(function() return sam.harmWeapon:isExist() end)
return ok and exists
end
--- Check if a SAM has at least one alive PD child.
function AEGIS:_HasLivePD(samName)
local sam = self.samSites[samName]
if not sam or not sam.pds then return false end
for _, pdName in ipairs(sam.pds) do
local pd = self.pdSites[pdName]
if pd and pd.state ~= AEGIS.STATE.DESTROYED then
local grp = Group.getByName(pdName)
if grp and grp:isExist() and grp:getSize() > 0 then
return true
end
end
end
return false
end
--- Force all PDs of a given parent to ALERT for HARM defense.
--- Immediate out-of-poll-cycle activation — PD needs to engage NOW.
function AEGIS:_ActivatePDsForHARM(parentName)
local sam = self.samSites[parentName]
if not sam or not sam.pds then return end
for _, pdName in ipairs(sam.pds) do
local pd = self.pdSites[pdName]
if pd and pd.state ~= AEGIS.STATE.DESTROYED then
self:_ApplyState(pdName, "pd", AEGIS.STATE.ALERT)
self:_Log(" PD " .. pdName .. ": ALERT for HARM defense")
end
end
end
--- Determine the appropriate HARM reaction for a SAM.
--- Pure decision function — no side effects.
--- @return "STAY_HOT" | "LAST_DITCH" | "GO_DARK"
function AEGIS:_DetermineHARMReaction(samName)
local sam = self.samSites[samName]
if not sam then return "GO_DARK" end
-- Multi-HARM saturation: per-SAM threshold (crew personality, randomized at init)
-- SP + live PD exception: crew has own engagement capability plus point defense — fight through it
if #sam.harmEvents >= sam.harmMultiThreshold then
if sam.sysData.selfProtect and self:_HasLivePD(samName) then
self:_Log(" " .. samName .. ": " .. #sam.harmEvents
.. " HARMs but SP+PD — fighting through saturation", true)
return "STAY_HOT"
end
self:_Log(" " .. samName .. ": " .. #sam.harmEvents
.. "/" .. sam.harmMultiThreshold .. " HARMs in "
.. self.harmMultiWindow .. "s -- MULTI-HARM override", true)
return "GO_DARK"
end
-- Nat 20: any crew might decide today's the day they earn a medal
if math.random(1, 100) <= self.harmBraveryPct then
self:_Log(" " .. samName .. ": DEFIANT — crew is fighting back!", true)
return "STAY_HOT"
end
-- Self-protect capable?
if sam.sysData.selfProtect then
-- Panic check: crew loses nerve
if math.random(1, 100) <= self.harmPanicPct then
self:_Log(" " .. samName .. ": selfProtect but crew PANICKED", true)
return "GO_DARK"
end
return "STAY_HOT"
end
-- Has a live PD that can try to engage the ARM?
if self:_HasLivePD(samName) then
return "LAST_DITCH"
end
-- Neither self-protect nor PD: classic GO_DARK
return "GO_DARK"
end
--- Execute the determined HARM reaction for a SAM.
--- Called after crew reaction delay. Checks SAM is still emitting before acting.
function AEGIS:_ExecuteHARMReaction(samName)
local sam = self.samSites[samName]
if not sam then return end
if sam.state == AEGIS.STATE.DESTROYED then return end
-- HARM takes priority over jammed EMCON — cleanly stop it
if sam.jammedEmconActive then
self:_StopJammedEMCON(samName)
end
-- If SAM went dark naturally during the reaction delay, skip reaction
-- but still set cooldown to prevent immediate re-ALERT
if not self:_IsEmitting(sam) then
self:_Log(" " .. samName .. ": went dark during crew delay, setting cooldown")
sam.harmCooldownUntil = timer.getTime() + math.random(self.harmCooldownMin, self.harmCooldownMax)
sam.harmReaction = nil
return
end
local reaction = self:_DetermineHARMReaction(samName)
local now = timer.getTime()
sam.harmReaction = reaction
if reaction == "STAY_HOT" then
self:_ExecuteStayHot(samName, now)
elseif reaction == "LAST_DITCH" then
self:_ExecuteLastDitch(samName, now)
else
self:_ExecuteGoDark(samName, now)
end
end
--- STAY_HOT: SAM has selfProtect capability, engaging ARM with own missiles.
--- Stops EMCON, switches to ALERT (weapons free), sets engagement window cooldown.
function AEGIS:_ExecuteStayHot(samName, now)
local sam = self.samSites[samName]
self:_Log(" " .. samName .. ": STAY_HOT (selfProtect, engaging ARM)", true)
-- Stop EMCON cycle to prevent timer interference
self:_StopEMCON(samName)
-- Ensure weapons free — needed if SAM was EMCON_OFF (weapon hold)
self:_ApplyState(samName, "sam", AEGIS.STATE.ALERT)
-- Set cooldown to prevent EW poll from de-escalating during engagement
sam.harmCooldownUntil = now + self.harmStayHotDuration
sam.harmReactionStart = now
-- After engagement window, check if HARM is still in flight before expiring
local aegis = self
local gen = sam.harmReactionGen
local function stayHotExpiry()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
-- HARM still in flight? Extend.
if aegis:_HARMStillInFlight(sam)
and (timer.getTime() - sam.harmReactionStart) < aegis.harmMaxCooldown then
aegis:_Log(samName .. ": STAY_HOT extending, HARM still in flight")
sam.harmCooldownUntil = timer.getTime() + aegis.harmExtendInterval
return timer.getTime() + aegis.harmExtendInterval
end
sam.harmReaction = nil
sam.harmWeapon = nil
aegis:_Log(samName .. ": STAY_HOT window expired, resuming normal ops")
return nil
end
timer.scheduleFunction(stayHotExpiry, nil, now + self.harmStayHotDuration)
end
--- LAST_DITCH: SAM has live PD but no self-protect. PD gets a window to engage
--- the ARM, then parent goes dark.
function AEGIS:_ExecuteLastDitch(samName, now)
local sam = self.samSites[samName]
local lastDitchDuration = math.random(self.harmLastDitchMin, self.harmLastDitchMax)
local cooldown = math.random(self.harmCooldownMin, self.harmCooldownMax)
self:_Log(" " .. samName .. ": LAST_DITCH (PD engaging, "
.. lastDitchDuration .. "s then GO_DARK, cooldown " .. cooldown .. "s)", true)
-- Stop EMCON cycle
self:_StopEMCON(samName)
-- Parent stays ALERT during last-ditch window
self:_ApplyState(samName, "sam", AEGIS.STATE.ALERT)
-- Force PDs to ALERT immediately (bypass poll cycle)
self:_ActivatePDsForHARM(samName)
-- Set cooldown covering the full last-ditch + post-dark period
sam.harmCooldownUntil = now + lastDitchDuration + cooldown
sam.harmReactionStart = now
-- Schedule GO_DARK after last-ditch window
local aegis = self
local gen = sam.harmReactionGen
timer.scheduleFunction(function()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
aegis:_Log(samName .. ": LAST_DITCH expired, GO_DARK", true)
aegis:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
sam.harmReaction = "GO_DARK"
end, nil, now + lastDitchDuration)
-- Schedule cooldown expiry with weapon-alive extension
local function lastDitchExpiry()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
-- HARM still in flight? Extend.
if aegis:_HARMStillInFlight(sam)
and (timer.getTime() - sam.harmReactionStart) < aegis.harmMaxCooldown then
aegis:_Log(samName .. ": cooldown extending, HARM still in flight")
sam.harmCooldownUntil = timer.getTime() + aegis.harmExtendInterval
return timer.getTime() + aegis.harmExtendInterval
end
sam.harmReaction = nil
sam.harmWeapon = nil
aegis:_Log(samName .. ": HARM cooldown expired, resuming normal ops")
return nil
end
timer.scheduleFunction(lastDitchExpiry, nil, now + lastDitchDuration + cooldown)
end
--- GO_DARK: No self-protect, no PD defense by default. Classic HARM dodge with jittered cooldown.
--- When triggered by panic or multi-HARM override (bypassing LAST_DITCH), activates PDs if available.
function AEGIS:_ExecuteGoDark(samName, now)
local sam = self.samSites[samName]
local cooldown = math.random(self.harmCooldownMin, self.harmCooldownMax)
self:_Log(" " .. samName .. ": GO_DARK (cooldown " .. cooldown .. "s)", true)
-- Stop EMCON cycle
self:_StopEMCON(samName)
-- Go dark
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
-- Activate PDs for HARM defense (covers panic/multi-HARM bypassing LAST_DITCH)
if self:_HasLivePD(samName) then
self:_ActivatePDsForHARM(samName)
end
-- Set cooldown
sam.harmCooldownUntil = now + cooldown
sam.harmReactionStart = now
-- Schedule cooldown expiry with weapon-alive extension
local aegis = self
local gen = sam.harmReactionGen
local function goDarkExpiry()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
-- HARM still in flight? Stay dark longer.
if aegis:_HARMStillInFlight(sam)
and (timer.getTime() - sam.harmReactionStart) < aegis.harmMaxCooldown then
aegis:_Log(samName .. ": cooldown extending, HARM still in flight")
sam.harmCooldownUntil = timer.getTime() + aegis.harmExtendInterval
return timer.getTime() + aegis.harmExtendInterval
end
sam.harmReaction = nil
sam.harmWeapon = nil
aegis:_Log(samName .. ": HARM cooldown expired, resuming normal ops")
return nil
end
timer.scheduleFunction(goDarkExpiry, nil, now + cooldown)
end
---------------------------------------------------------------------------
-- PB HARM NETWORK WARNING (Phase 5)
-- S_EVENT_SHOT catches PB ARM launch → delay → poll weapon for trajectory →
-- project ray → warn networked SAMs in the path.
---------------------------------------------------------------------------
--- Compute EW detection delay for a PB HARM based on range from HARM to sector EWs.
--- Score-per-sweep model: each sweep, EWs contribute detection score based on range.
--- Multiple EWs in sector sum scores each sweep independently.
--- @return delay in seconds, or math.huge if no live EWs can detect
function AEGIS:_ComputeEWDetectionDelay(harmPos, sectorName)
local sec = self.sectors[sectorName]
if not sec then return math.huge end
-- Sum per-sweep score across all live, powered EWs in sector
local combinedScore = 0
for _, ewName in ipairs(sec.ew) do
local ew = self.ewRadars[ewName]
if ew and ew.state ~= AEGIS.STATE.DESTROYED and ew.pos and self:_NodeHasPower(ew) then
local g = Group.getByName(ewName)
if g and g:isExist() then
local dx = harmPos.x - ew.pos.x
local dz = harmPos.z - ew.pos.z
local distM = math.sqrt(dx * dx + dz * dz)
local distNM = distM / AEGIS.NM_TO_M
-- Skip this EW if HARM is beyond its detection range
if ew.detRange > 0 and distNM > ew.detRange then
-- EW can't see this far, contributes zero score
else
-- Look up score from table
local score = AEGIS.PB_HARM_SCORE_FLOOR
for _, entry in ipairs(AEGIS.PB_HARM_SCORE_TABLE) do
if distNM <= entry.maxRange then
score = entry.score
break
end
end
combinedScore = combinedScore + score
end
end
end
end
if combinedScore <= 0 then return math.huge end
-- Sweeps needed to reach detection threshold
local sweeps = math.ceil(self.pbHarmDetThreshold / combinedScore)
if sweeps < 2 then sweeps = 2 end -- minimum 2 sweeps for a track file
local delay = sweeps * self.pbHarmSweepPeriod
if delay < self.pbHarmDetFloor then delay = self.pbHarmDetFloor end
return delay
end
--- Check a PB HARM's trajectory and warn networked SAMs in its path.
--- Called once, ~2s after PB launch, when weapon velocity has stabilized.
function AEGIS:_CheckPBHARMTrajectory(weapon)
-- Is weapon still alive?
local existOk, exists = pcall(function() return weapon:isExist() end)
if not existOk or not exists then return end
-- Get position and velocity
local posOk, pos = pcall(function() return weapon:getPoint() end)
local velOk, vel = pcall(function() return weapon:getVelocity() end)
if not posOk or not pos or not velOk or not vel then return end
-- Need meaningful horizontal velocity to project
local vx, vz = vel.x, vel.z
local speedSq = vx*vx + vz*vz
if speedSq < 100 then return end -- < 10 m/s horizontal, can't project
local warnRadiusM = self.pbHarmWarnRadius * AEGIS.NM_TO_M
local warnRadiusSq = warnRadiusM * warnRadiusM
local warned = 0
local now = timer.getTime()
for samName, sam in pairs(self.samSites) do
if sam.state ~= AEGIS.STATE.DESTROYED and sam.pos then
-- Ray projection: closest point on trajectory to SAM position
-- Runs for ALL SAMs regardless of EW coverage (harmInbound is universal)
local wx = sam.pos.x - pos.x
local wz = sam.pos.z - pos.z
local dotWV = wx*vx + wz*vz
local t = dotWV / speedSq -- t > 0 = SAM is ahead of HARM
if t > 0 then
local cx = pos.x + t * vx
local cz = pos.z + t * vz
local dx = sam.pos.x - cx
local dz = sam.pos.z - cz
local missDistSq = dx*dx + dz*dz
if missDistSq <= warnRadiusSq then
local missNM = math.sqrt(missDistSq) / AEGIS.NM_TO_M
self:_Log(" PB HARM trajectory -> " .. samName
.. " (miss: " .. string.format("%.1f", missNM) .. " NM"
.. ", ETA: " .. string.format("%.0f", t) .. "s)", true)
-- 1. ALWAYS set harmInbound flag (own-radar detection path)
sam.harmInbound = now
sam.harmInboundExpiry = now + t + self.pbHarmInboundMargin
self:_Log(" " .. samName .. ": harmInbound set (expires in "
.. string.format("%.0f", t + self.pbHarmInboundMargin) .. "s)")
-- 2. EW network warning path: only if SAM has a live EW
if sam.sector and self:_SectorHasEW(sam.sector) then
local ewDelay = self:_ComputeEWDetectionDelay(pos, sam.sector)
local unitReaction = math.random(self.pbHarmEwReactionMin, self.pbHarmEwReactionMax)
local totalDelay = ewDelay + unitReaction
if totalDelay < t then
-- EW warning arrives before HARM impact — schedule it
self:_Log(" " .. samName .. ": EW detection delay "
.. string.format("%.0f", ewDelay) .. "s + unit reaction "
.. unitReaction .. "s = " .. string.format("%.0f", totalDelay) .. "s")
local aegis = self
timer.scheduleFunction(function()
aegis:_WarnSAMofPBHARM(samName, weapon, t - totalDelay)
return nil
end, nil, now + totalDelay)
else
self:_Log(" " .. samName .. ": EW too slow (delay "
.. string.format("%.0f", totalDelay)
.. "s > ETA " .. string.format("%.0f", t)
.. "s), harmInbound only", true)
end
else
self:_Log(" " .. samName .. ": no EW in sector, harmInbound only")
end
warned = warned + 1
end
end
end
end
if warned == 0 then
self:_Log(" PB HARM trajectory: no SAMs in path", true)
end
end
--- Warn a specific SAM about an inbound PB HARM detected by the network.
--- Decision depends on SAM's current state and capabilities.
function AEGIS:_WarnSAMofPBHARM(samName, weapon, eta)
local sam = self.samSites[samName]
if not sam then return end
local now = timer.getTime()
local suppressDuration = eta + self.pbHarmCooldownMargin
if self:_IsEmitting(sam) then
-- SAM is emitting: treat like a direct HARM reaction
-- Record event for multi-HARM tracking
self:_RecordHARMEvent(sam, now)
sam.harmWeapon = weapon
-- Use crew reaction delay + normal decision tree
local delay = math.random(self.harmReactionDelayMin, self.harmReactionDelayMax)
sam.harmReactionGen = sam.harmReactionGen + 1
local gen = sam.harmReactionGen
local aegis = self
-- Freeze state during crew processing — prevents frustration/poll from
-- overriding a pending HARM reaction
sam.harmCooldownUntil = now + delay
self:_Log(" " .. samName .. ": PB HARM warning (emitting), crew processing ("
.. delay .. "s)...", true)
timer.scheduleFunction(function()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
aegis:_ExecuteHARMReaction(samName)
return nil
end, nil, now + delay)
else
-- SAM is dark: suppress EMCON sweep, keep it quiet
-- Extend harmCooldownUntil so the poll won't push it to ALERT
-- and EMCON won't start a sweep during the HARM's flight window
local newCooldown = now + suppressDuration
if newCooldown > sam.harmCooldownUntil then
sam.harmCooldownUntil = newCooldown
-- If SAM is in EMCON cycle, stop it to prevent a sweep
if sam.state == AEGIS.STATE.EMCON_ON or sam.state == AEGIS.STATE.EMCON_OFF
or sam.state == AEGIS.STATE.EMCON_ENGAGED then
self:_StopEMCON(samName)
self:_ApplyState(samName, "sam", AEGIS.STATE.DARK)
end
sam.harmReaction = "PB_SUPPRESS"
self:_Log(" " .. samName .. ": PB HARM warning (dark), suppressing for "
.. string.format("%.0f", suppressDuration) .. "s", true)
-- Activate PD if available — PD defends while parent stays dark
if self:_HasLivePD(samName) then
self:_ActivatePDsForHARM(samName)
self:_Log(" " .. samName .. ": PD activated for PB HARM defense")
end
-- Schedule suppression expiry
local aegis = self
local gen = sam.harmReactionGen
timer.scheduleFunction(function()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end
sam.harmReaction = nil
sam.harmCooldownUntil = 0
aegis:_Log(samName .. ": PB HARM suppression expired, resuming normal ops")
return nil
end, nil, newCooldown)
else
self:_Log(" " .. samName .. ": PB HARM warning, already suppressed")
end
end
end
--- Handle S_EVENT_SHOT: detect anti-radiation missiles targeting our SAMs.
function AEGIS:_OnShot(event)
local wpn = event.weapon
if not wpn then return end
-- Check if weapon is an ARM via descriptor
local descOk, desc = pcall(function() return wpn:getDesc() end)
if not descOk or not desc then return end
if desc.missileCategory ~= AEGIS.HARM_MISSILE_CATEGORY then return end
if desc.guidance ~= AEGIS.HARM_GUIDANCE then return end -- Filter out TALDs (guidance=1)
-- It's an ARM. Get weapon type name for logging.
local typeOk, typeName = pcall(function() return wpn:getTypeName() end)
typeName = typeOk and typeName or "unknown ARM"
-- Who fired it?
local shooterName = "unknown"
if event.initiator then
local snOk, sn = pcall(function() return event.initiator:getName() end)
shooterName = snOk and sn or "unknown"
end
self:_Log("*** HARM LAUNCH: " .. typeName .. " by " .. shooterName, true)
-- TOO/SP mode: getTarget() returns the specific unit being targeted
local tgtOk, target = pcall(function() return wpn:getTarget() end)
if not tgtOk or not target then
-- PB mode: no direct target. Schedule trajectory check for network warning.
self:_Log(" HARM target: nil (PB mode) -- scheduling trajectory check", true)
local wpnRef = wpn
local aegis = self
timer.scheduleFunction(function()
aegis:_CheckPBHARMTrajectory(wpnRef)
return nil
end, nil, timer.getTime() + self.pbHarmCheckDelay)
return
end
-- Get the target unit's group name -- that's our key into samSites
local grpOk, targetGrp = pcall(function() return target:getGroup() end)
if not grpOk or not targetGrp then
self:_Log(" HARM target unit has no group -- ignoring")
return
end
local grpNameOk, targetGrpName = pcall(function() return targetGrp:getName() end)
if not grpNameOk or not targetGrpName then
self:_Log(" HARM target group name unavailable -- ignoring")
return
end
-- Is this one of our tracked SAMs?
local sam = self.samSites[targetGrpName]
if not sam then
-- HARM targeting a PD? Redirect to parent SAM — co-located, same threat
local pd = self.pdSites[targetGrpName]
if pd and pd.parent then
sam = self.samSites[pd.parent]
if sam then
self:_Log(" HARM target: " .. targetGrpName .. " (PD of " .. pd.parent .. "), treating as parent HARM")
targetGrpName = pd.parent
end
end
if not sam then
self:_Log(" HARM target: " .. targetGrpName .. " (not a tracked SAM)")
return
end
end
-- Is this SAM currently emitting?
if not self:_IsEmitting(sam) then
self:_Log(" " .. targetGrpName .. ": HARM inbound but NOT emitting -- ignoring")
return
end
-- Detection range gate: SAM tracking radar has finite detection range against small ARM RCS.
-- HARMs launched beyond harmDetectionRange are not detected until they close to that range.
local now = timer.getTime()
local detectionDelay = 0
if self.harmDetectionRange > 0 and sam.pos then
local wpnPosOk, wpnPos = pcall(function() return wpn:getPoint() end)
if wpnPosOk and wpnPos then
local dx = wpnPos.x - sam.pos.x
local dz = wpnPos.z - sam.pos.z
local distM = math.sqrt(dx * dx + dz * dz)
local detRangeM = self.harmDetectionRange * AEGIS.NM_TO_M
if distM > detRangeM then
-- Compute time for HARM to close from launch distance to detection range
detectionDelay = (distM - detRangeM) / AEGIS.HARM_SPEED
self:_Log(" " .. targetGrpName .. ": HARM at " .. math.floor(distM / AEGIS.NM_TO_M)
.. " NM, detection gated at " .. self.harmDetectionRange
.. " NM (+" .. math.floor(detectionDelay) .. "s)", true)
end
end
end
-- Record HARM event for multi-HARM saturation tracking
self:_RecordHARMEvent(sam, now)
-- Store weapon reference for in-flight tracking (cooldown extension)
sam.harmWeapon = wpn
-- Problem 1 fix: crew reacts to FIRST HARM only. Subsequent HARMs increment the
-- multi-HARM counter but do NOT restart the reaction delay timer.
if sam.harmReactionPending then
self:_Log(" " .. targetGrpName .. ": additional HARM (#" .. #sam.harmEvents
.. ") -- counter updated, crew already processing", true)
return
end
-- Schedule delayed reaction (crew processing time)
local crewDelay = math.random(self.harmReactionDelayMin, self.harmReactionDelayMax)
local totalDelay = detectionDelay + crewDelay
sam.harmReactionGen = sam.harmReactionGen + 1
local gen = sam.harmReactionGen
local aegis = self
sam.harmReactionPending = true
-- Freeze state during crew processing — prevents frustration/poll from
-- overriding a pending HARM reaction
sam.harmCooldownUntil = now + totalDelay
if detectionDelay > 0 then
self:_Log(" " .. targetGrpName .. ": HARM detected in " .. math.floor(detectionDelay)
.. "s, crew processing +" .. crewDelay .. "s (" .. math.floor(totalDelay) .. "s total)...", true)
else
self:_Log(" " .. targetGrpName .. ": HARM detected, crew processing (" .. crewDelay .. "s)...", true)
end
timer.scheduleFunction(function()
if sam.state == AEGIS.STATE.DESTROYED then return nil end
if sam.harmReactionGen ~= gen then return nil end -- superseded
sam.harmReactionPending = false
aegis:_ExecuteHARMReaction(targetGrpName)
return nil
end, nil, now + totalDelay)
end
---------------------------------------------------------------------------
-- UTILITIES
---------------------------------------------------------------------------
function AEGIS:_Dist(a, b)
local dx, dy, dz = a.x-b.x, a.y-b.y, a.z-b.z
return math.sqrt(dx*dx + dy*dy + dz*dz)
end
function AEGIS:_NodeCount()
local c = 0
for _ in pairs(self.ewRadars) do c=c+1 end
for _ in pairs(self.samSites) do c=c+1 end
for _ in pairs(self.pdSites) do c=c+1 end
for _ in pairs(self.powerSources) do c=c+1 end
for _ in pairs(self.commandCenters) do c=c+1 end
for _ in pairs(self.jammers) do c=c+1 end
for _ in pairs(self.pendingNodes) do c=c+1 end
return c
end
function AEGIS:_Log(msg, warn)
if self.debug or warn then
local p = warn and "[AEGIS!] " or "[AEGIS] "
env.info(p .. msg)
if self.debug then trigger.action.outText(p .. msg, 8) end
end
end
function AEGIS:_PrintTopology()
self:_Log("=== TOPOLOGY ===")
for name, sec in pairs(self.sectors) do
if name ~= "_AUTO" then
self:_Log("[" .. name .. "] EW:" .. #sec.ew .. " SAM:" .. #sec.sams
.. " PD:" .. #sec.pds .. " CMD:" .. #sec.cmd)
end
end
local pwrCount = 0
for _ in pairs(self.powerSources) do pwrCount = pwrCount + 1 end
if pwrCount > 0 then
self:_Log("PWR sources: " .. pwrCount .. " (per-node)")
for pwrName, pwr in pairs(self.powerSources) do
if #pwr.linkedTo > 0 then
self:_Log(" " .. pwrName .. " -> " .. table.concat(pwr.linkedTo, ", "))
else
self:_Log(" " .. pwrName .. " -> UNLINKED")
end
end
end
local jamCount = 0
for _ in pairs(self.jammers) do jamCount = jamCount + 1 end
if jamCount > 0 then
self:_Log("EA jammers: " .. jamCount)
for jamName, j in pairs(self.jammers) do
self:_Log(" " .. jamName .. " [" .. j.jamType .. "] "
.. (j.active and "ACTIVE" or "INACTIVE")
.. (j.playerControlled and " (player)" or " (AI)"))
end
end
local pendCount = 0
for _ in pairs(self.pendingNodes) do pendCount = pendCount + 1 end
if pendCount > 0 then
self:_Log("Pending (late activation): " .. pendCount)
for pName, p in pairs(self.pendingNodes) do
self:_Log(" " .. pName .. " [" .. p.nodeType .. "]")
end
end
end
---------------------------------------------------------------------------
-- F10 MAP DEBUG
---------------------------------------------------------------------------
function AEGIS:_NextMarkerId()
AEGIS._markerId = AEGIS._markerId + 1
return AEGIS._markerId
end
function AEGIS:StartMapDebug(interval)
interval = interval or 15
self.mapMarkerIds = {}
local aegis = self
local function refresh()
aegis:_UpdateMapMarkers()
return timer.getTime() + interval
end
timer.scheduleFunction(refresh, nil, timer.getTime() + 5)
self:_Log("Map debug on (every " .. interval .. "s)")
end
function AEGIS:_UpdateMapMarkers()
-- Clear old
for _, id in ipairs(self.mapMarkerIds) do
trigger.action.removeMark(id)
end
self.mapMarkerIds = {}
-- EW radars
for name, n in pairs(self.ewRadars) do
local g = Group.getByName(name)
if g and g:isExist() then
local u = g:getUnit(1)
if u then
local id = self:_NextMarkerId()
local txt = "EW: " .. name
.. "\nSector: " .. n.sector
.. "\nState: " .. (n.state == AEGIS.STATE.DESTROYED and "DESTROYED" or (n.hasContacts and "ACTIVE *CONTACTS*" or "ACTIVE"))
if n.detRange > 0 then
txt = txt .. "\nDetection: " .. n.detRange .. " NM"
end
local sec = self.sectors[n.sector]
if sec and sec.jammed then
txt = txt .. "\n*** SECTOR JAMMED ***"
end
trigger.action.markToAll(id, txt, u:getPoint())
table.insert(self.mapMarkerIds, id)
end
end
end
-- SAM sites
for name, n in pairs(self.samSites) do
if n.state ~= AEGIS.STATE.DESTROYED and n.pos then
local g = Group.getByName(name)
if g and g:isExist() then
local id = self:_NextMarkerId()
local zone = self.siteZoneOverrides[name] or self.defaultZone
local siteRange = self.siteRangeOverrides[name]
local rangeNM
if zone == "NEZ" then
rangeNM = (siteRange and siteRange.nez) or n.sysData.nez
else
rangeNM = (siteRange and siteRange.wez) or n.sysData.wez
end
local actNM = self.siteActRangeOverrides[name] or n.sysData.actRange or n.sysData.wez
local txt = "SAM: " .. name
.. "\nType: " .. n.sysType .. " [" .. n.sysData.cat .. (n.sysData.selfProtect and " SP" or "") .. (n.mobile and " MOB" or "") .. "]"
.. "\nSector: " .. (n.sector or "?")
.. "\nState: " .. n.state
.. "\n" .. zone .. ": " .. rangeNM .. " NM | ACT: " .. actNM .. " NM"
.. "\nAlt: " .. n.sysData.altMin .. "-" .. n.sysData.altMax .. " ft"
if n.powerSource then
txt = txt .. "\nPWR: " .. n.powerSource .. (self:_NodeHasPower(n) and " [ON]" or " [OFF]")
end
-- Show EMCON jitter info in debug
if n.state == AEGIS.STATE.EMCON_ON or n.state == AEGIS.STATE.EMCON_OFF then
txt = txt .. "\nSweeps w/o detect: " .. n.sweepsSinceDetect
if n.spooked then txt = txt .. " *SPOOKED*" end
end
if n.harmCooldownUntil > timer.getTime() then
local remaining = math.ceil(n.harmCooldownUntil - timer.getTime())
local reaction = n.harmReaction or "GO_DARK"
if reaction == "STAY_HOT" and not n.sysData.selfProtect then
txt = txt .. "\n*** HARM: DEFIANT (crew fighting back!) " .. remaining .. "s ***"
elseif reaction == "STAY_HOT" then
txt = txt .. "\n*** HARM: STAY_HOT (selfProtect) " .. remaining .. "s ***"
elseif reaction == "LAST_DITCH" then
txt = txt .. "\n*** HARM: LAST_DITCH (PD engaging) " .. remaining .. "s ***"
elseif reaction == "PB_SUPPRESS" then
txt = txt .. "\n*** PB HARM: SUPPRESSED (network warn) " .. remaining .. "s ***"
else
txt = txt .. "\n*** HARM DODGE: " .. remaining .. "s ***"
end
end
if self:_CheckHarmInbound(n) then
local remaining = math.ceil(n.harmInboundExpiry - timer.getTime())
txt = txt .. "\n*** PB HARM INBOUND: " .. remaining .. "s ***"
end
if n.hojUntil > 0 and n.hojUntil > timer.getTime() then
local remaining = math.ceil(n.hojUntil - timer.getTime())
txt = txt .. "\n*** HOJ — WEAPONS FREE: " .. remaining .. "s ***"
end
if n.jammedEmconActive then
if n.state == AEGIS.STATE.ALERT then
txt = txt .. "\n*** BURN-THROUGH (EA) ***"
else
txt = txt .. "\n*** JAMMED EMCON (EA) ***"
end
elseif n.jammed then
txt = txt .. "\n*** JAMMED (EA) ***"
end
trigger.action.markToAll(id, txt, n.pos)
table.insert(self.mapMarkerIds, id)
end
end
end
-- PD sites
for name, n in pairs(self.pdSites) do
if n.state ~= AEGIS.STATE.DESTROYED and n.pos then
local g = Group.getByName(name)
if g and g:isExist() then
local id = self:_NextMarkerId()
local txt = "PD: " .. name
.. "\nType: " .. n.sysType
.. "\nParent: " .. (n.parent or "none")
.. "\nState: " .. n.state
-- Show HARM defense status when PD is ALERT due to parent cooldown
if n.parent and n.state == AEGIS.STATE.ALERT then
local parentSam = self.samSites[n.parent]
if parentSam and parentSam.harmCooldownUntil and timer.getTime() < parentSam.harmCooldownUntil then
local remaining = math.ceil(parentSam.harmCooldownUntil - timer.getTime())
txt = txt .. "\n*** HARM DEFENSE: " .. remaining .. "s ***"
end
end
trigger.action.markToAll(id, txt, n.pos)
table.insert(self.mapMarkerIds, id)
end
end
end
-- Power
for name, n in pairs(self.powerSources) do
local pos = nil
local g = Group.getByName(name)
if g and g:isExist() then
local u = g:getUnit(1); if u then pos = u:getPoint() end
else
local s = StaticObject.getByName(name)
if s and s:isExist() then pos = s:getPoint() end
end
if pos then
local id = self:_NextMarkerId()
local txt = "PWR: " .. name
.. "\nTarget: " .. (n.targetHint or "unknown")
.. "\nLinked: " .. (#n.linkedTo > 0 and table.concat(n.linkedTo, ", ") or "none")
.. "\nStatus: " .. (n.alive and "ONLINE" or "** OFFLINE **")
trigger.action.markToAll(id, txt, pos)
table.insert(self.mapMarkerIds, id)
end
end
-- Command
for name, n in pairs(self.commandCenters) do
local g = Group.getByName(name)
if g and g:isExist() then
local u = g:getUnit(1)
if u then
local id = self:_NextMarkerId()
local txt = "CMD: " .. name
.. "\nSector: " .. n.sector
.. "\nStatus: " .. (n.alive and "ONLINE" or "** DESTROYED **")
trigger.action.markToAll(id, txt, u:getPoint())
table.insert(self.mapMarkerIds, id)
end
end
end
-- EA Jammers
for name, j in pairs(self.jammers) do
if j.alive and j.pos then
local id = self:_NextMarkerId()
local bl = self.jammerBaseline
local modeLabels = { OMNI="FULL OMNI", WIDE="WIDE", DIR2="2xDIR", OFF="OFF" }
local hdgDeg = math.floor(math.deg(j.heading) + 0.5) % 360
local txt = "EA: " .. name
.. "\nType: " .. j.jamType .. " (x" .. j.mult .. ")"
.. "\nMode: " .. (modeLabels[j.mode] or j.mode)
.. "\nActive: " .. (j.active and "YES" or "NO")
.. "\nHDG: " .. hdgDeg
.. "\nRange: " .. bl.effectRange .. " NM (omni) / "
.. (bl.effectRange * bl.directionalRangeMult) .. " NM (dir)"
if j.mode == "WIDE" then
if j.bearingLocked then
txt = txt .. "\nPod 1: WIDE BRG " .. (math.floor(math.deg(j.lockedBearing) + 0.5) % 360)
else
txt = txt .. "\nPod 1: WIDE HDG"
end
txt = txt .. "\nPod 2: " .. (j.pod2Target or "unassigned")
elseif j.mode == "DIR2" then
txt = txt .. "\nPod 1: " .. (j.pod1Target or "unassigned")
txt = txt .. "\nPod 2: " .. (j.pod2Target or "unassigned")
end
txt = txt .. (j.playerControlled and "\n(Player)" or "\n(AI)")
trigger.action.markToAll(id, txt, j.pos)
table.insert(self.mapMarkerIds, id)
end
end
end
---------------------------------------------------------------------------
-- F10 MENU
---------------------------------------------------------------------------
function AEGIS:GetStatusReport()
local l = { "=== AEGIS " .. self.side .. " ===" }
for name, sec in pairs(self.sectors) do
if name ~= "_AUTO" then
local jamTag = ""
if sec.jammed then jamTag = " JAM:YES" end
table.insert(l, "\n[" .. name .. "]"
.. " C2:" .. (self:_SectorHasC2(name) and "UP" or "DOWN")
.. " EW:" .. (self:_SectorHasEW(name) and "UP" or "DOWN")
.. jamTag)
for _, s in ipairs(sec.sams) do
local n = self.samSites[s]
if n then
local pwrStr = ""
if n.powerSource then
pwrStr = self:_NodeHasPower(n) and " PWR:ON" or " PWR:OFF"
end
local harmStr = ""
if n.harmCooldownUntil > timer.getTime() then
local remaining = math.ceil(n.harmCooldownUntil - timer.getTime())
local reaction = n.harmReaction or "GO_DARK"
harmStr = " *HARM:" .. reaction .. " " .. remaining .. "s*"
end
local hojStr = ""
if n.hojUntil > 0 and n.hojUntil > timer.getTime() then
local remaining = math.ceil(n.hojUntil - timer.getTime())
hojStr = " *HOJ:" .. remaining .. "s*"
end
local jamStr = ""
if n.jammedEmconActive and n.state == AEGIS.STATE.ALERT then
jamStr = " *BURN-THRU*"
elseif n.jammedEmconActive then
jamStr = " *JAM-EMCON*"
elseif n.jammed then
jamStr = " *JAMMED*"
end
table.insert(l, " " .. s .. " [" .. n.state .. "]" .. pwrStr .. harmStr .. hojStr .. jamStr)
end
end
for _, p in ipairs(sec.pds) do
local n = self.pdSites[p]
if n then table.insert(l, " " .. p .. " [" .. n.state .. "] -> " .. (n.parent or "?")) end
end
end
end
-- EA jammers
local jamCount = 0
local jamActive = 0
for _, j in pairs(self.jammers) do
if j.alive then
jamCount = jamCount + 1
if j.active then jamActive = jamActive + 1 end
end
end
if jamCount > 0 then
table.insert(l, "\n[EA] " .. jamActive .. "/" .. jamCount .. " active")
local modeLabels = { OMNI="OMNI", WIDE="WIDE", DIR2="2xDIR", OFF="OFF" }
for name, j in pairs(self.jammers) do
if j.alive then
table.insert(l, " " .. name .. " [" .. j.jamType .. " x" .. j.mult .. "] "
.. (modeLabels[j.mode] or j.mode)
.. (j.active and " ON" or " OFF")
.. (j.playerControlled and " (player)" or " (AI)"))
end
end
end
return table.concat(l, "\n")
end
function AEGIS:ShowStatus(dur)
trigger.action.outText(self:GetStatusReport(), dur or 15)
end
function AEGIS:AddF10Menu(menuName)
menuName = menuName or "AEGIS IADS"
local root = missionCommands.addSubMenu(menuName)
local a = self
missionCommands.addCommand("Status", root, function() a:ShowStatus(20) end)
missionCommands.addCommand("Topology", root, function() a:_PrintTopology() end)
missionCommands.addCommand("Refresh Map", root, function() a:_UpdateMapMarkers() end)
end
---------------------------------------------------------------------------
-- EA GUI BRIDGE
-- Global functions called by the EA socket listener (below) and available
-- to any hook script overlay. Query and command the jammer system.
---------------------------------------------------------------------------
--- Find the jammer group containing a player by their DCS player name.
--- Uses explicit jammerPlayers tracking table (populated by _OnBirthEA and _FindJammerBySlot).
--- Returns jammerName, jammerTable or nil, nil.
function AEGIS:_FindJammerByPlayer(playerName)
local groupName = self.jammerPlayers[playerName]
if groupName then
local j = self.jammers[groupName]
if j and j.alive then return groupName, j end
end
return nil, nil
end
--- Find the jammer group by DCS slot ID (e.g., "207" or "207_2").
--- Strips seat suffix (_2, _3) to get unit ID, then looks up eaUnitMap.
--- On success, caches playerName in jammerPlayers for future fast lookups.
--- Returns jammerName, jammerTable or nil, nil.
function AEGIS:_FindJammerBySlot(slot, playerName)
if not slot then return nil, nil end
-- Strip seat suffix: "207_2" → "207"
local unitId = slot:match("^(%d+)")
if not unitId then return nil, nil end
local groupName = self.eaUnitMap[unitId]
if not groupName then return nil, nil end
local j = self.jammers[groupName]
if not j or not j.alive then return nil, nil end
-- Cache for future lookups
if playerName then
self.jammerPlayers[playerName] = groupName
end
return groupName, j
end
--- Global state query for hook script GUI.
--- Returns pipe-delimited string (11 fields):
--- groupName|mode|active|heading|brgLocked|lockedBrg|p1Target;absBrg|p2Target;absBrg|emitters|magDec|widePreset
--- where pod fields = "name;absBrg" (bearing from jammer position, independent of emission),
--- and emitters = comma-separated "name;displayLabel;brg;strength;staleFlag;rangeStr" entries.
--- rangeStr = ESM range estimate ("", "40-80", "40-60", "~48", with "?" suffix if stale).
--- Returns "" if no AEGIS instance, player not in a jammer, or jammer not alive.
function AEGIS_EA_GET_STATE(playerName, slot)
if not AEGIS._instance then return "" end
local inst = AEGIS._instance
local jamName, j = inst:_FindJammerByPlayer(playerName)
if not jamName or not j then
-- Fallback: slot-based lookup (copilot/WSO seats don't fire S_EVENT_BIRTH)
jamName, j = inst:_FindJammerBySlot(slot, playerName)
end
if not jamName or not j then
-- SP fallback: slot maps to a jammer but name lookup failed (SP name mismatch).
-- If slot doesn't map to a jammer unit, the player isn't in one — no guessing.
if slot then
local baseSlot = slot:match("^(%d+)")
if baseSlot and inst.eaUnitMap[baseSlot] then
local gn = inst.eaUnitMap[baseSlot]
local jj = inst.jammers[gn]
if jj and jj.alive then
jamName, j = gn, jj
inst.jammerPlayers[playerName] = gn
inst:_Log("EA GUI: SP fallback (slot " .. baseSlot .. ") matched " .. playerName .. " -> " .. gn, true)
end
end
end
end
if not jamName or not j then return "" end
local hdg = math.floor(math.deg(j.heading) + 0.5) % 360
local brgLocked = j.bearingLocked and "1" or "0"
local lockedBrg = math.floor(math.deg(j.lockedBearing) + 0.5) % 360
-- Pod targets: encode as "name;absBrg" so hook has position-based bearing
local p1 = j.pod1Target or ""
if j.pod1Target and j.pos then
local brg = inst:_PodTargetRelBrg(j.pos, 0, j.pod1Target) -- hdg=0 gives abs bearing
if brg then p1 = p1 .. ";" .. brg end
end
local p2 = j.pod2Target or ""
if j.pod2Target and j.pos then
local brg = inst:_PodTargetRelBrg(j.pos, 0, j.pod2Target) -- hdg=0 gives abs bearing
if brg then p2 = p2 .. ";" .. brg end
end
-- Build emitter list from knownEmitters (name;displayLabel;absBrg;strength;stale;rangeStr)
local emitterParts = {}
if j.knownEmitters and j.pos then
for eName, eData in pairs(j.knownEmitters) do
local ePos, refRange = nil, 200
local isEW = eData.isEW
local sysType = eData.sysType
local sam = inst.samSites[eName]
if sam and sam.pos then
ePos = sam.pos
refRange = sam.sysData and sam.sysData.actRange or 30
else
local ew = inst.ewRadars[eName]
if ew and ew.pos then
ePos = ew.pos
refRange = (ew.detRange > 0) and ew.detRange or 200
end
end
if ePos then
local brg = math.deg(math.atan2(ePos.z - j.pos.z, ePos.x - j.pos.x))
brg = math.floor(brg + 0.5) % 360
local distNM = math.sqrt(eData.distSq) / AEGIS.NM_TO_M
local str = inst:_EmitterStrength(distNM, refRange)
local displayLabel = inst:_EmitterLabel(eName, sysType, isEW)
local staleFlag = (eData.stale and "1" or "0")
local rangeStr = AEGIS._FormatESMRange(distNM, eData.rangeConf, eData.stale)
table.insert(emitterParts, eName .. ";" .. displayLabel .. ";" .. brg .. ";" .. str .. ";" .. staleFlag .. ";" .. rangeStr)
end
end
end
local active = j.active and "1" or "0"
local magDec = j.magDeclination and tostring(j.magDeclination) or ""
return jamName .. "|" .. j.mode .. "|" .. active .. "|" .. hdg
.. "|" .. brgLocked .. "|" .. lockedBrg
.. "|" .. p1 .. "|" .. p2
.. "|" .. table.concat(emitterParts, ",")
.. "|" .. magDec
.. "|" .. (j.widePreset or "W70")
end
--- Global command function for hook script GUI.
--- Parses colon-delimited command, executes, rebuilds F10 menu.
--- Returns "OK" or "ERR:reason".
function AEGIS_EA_CMD(playerName, cmdStr, slot)
if not AEGIS._instance then return "ERR:no instance" end
local inst = AEGIS._instance
local jamName, j = inst:_FindJammerByPlayer(playerName)
if not jamName or not j then
jamName, j = inst:_FindJammerBySlot(slot, playerName)
end
if not jamName or not j then
-- SP fallback: slot maps to a jammer but name lookup failed (SP name mismatch).
-- If slot doesn't map to a jammer unit, the player isn't in one — no guessing.
if slot then
local baseSlot = slot:match("^(%d+)")
if baseSlot and inst.eaUnitMap[baseSlot] then
local gn = inst.eaUnitMap[baseSlot]
local jj = inst.jammers[gn]
if jj and jj.alive then
jamName, j = gn, jj
inst.jammerPlayers[playerName] = gn
inst:_Log("EA GUI: SP fallback (slot " .. baseSlot .. ") matched " .. playerName .. " -> " .. gn, true)
end
end
end
end
if not jamName or not j then return "ERR:not in jammer" end
-- Parse colon-delimited command
local parts = {}
for part in cmdStr:gmatch("[^:]+") do
table.insert(parts, part)
end
local cmd = parts[1]
if cmd == "SET_MODE" then
local modeKey = parts[2]
if modeKey == "OMNI" then
j.mode = "OMNI"; j.active = true
j.pod1Target = nil; j.pod2Target = nil; j.bearingLocked = false
elseif modeKey == "WIDE" then
j.mode = "WIDE"; j.active = true
j.pod1Target = nil -- pod 1 is cone in this mode
elseif modeKey == "DIR2" then
j.mode = "DIR2"; j.active = true
elseif modeKey == "OFF" then
j.mode = "OFF"; j.active = false
j.pod1Target = nil; j.pod2Target = nil; j.bearingLocked = false
else
return "ERR:unknown mode"
end
inst:_Log(jamName .. ": EA mode -> " .. modeKey .. " (GUI)", true)
elseif cmd == "SET_POD" then
local podNum = tonumber(parts[2])
local target = parts[3]
if not target then return "ERR:no target" end
-- Clear the other pod if it was targeting the same emitter (swap, not duplicate)
if podNum == 1 then
if j.pod2Target == target then j.pod2Target = nil end
j.pod1Target = target
elseif podNum == 2 then
if j.pod1Target == target then j.pod1Target = nil end
j.pod2Target = target
else return "ERR:invalid pod" end
inst:_Log(jamName .. ": pod" .. podNum .. " -> " .. target .. " (GUI)", true)
elseif cmd == "UNASSIGN" then
local podNum = tonumber(parts[2])
if podNum == 1 then j.pod1Target = nil
elseif podNum == 2 then j.pod2Target = nil
else return "ERR:invalid pod" end
inst:_Log(jamName .. ": pod" .. podNum .. " unassigned (GUI)", true)
elseif cmd == "LOCK_BRG" then
j.bearingLocked = true
j.lockedBearing = j.heading
inst:_Log(jamName .. ": BRG locked (GUI)", true)
elseif cmd == "UNLOCK_BRG" then
j.bearingLocked = false
inst:_Log(jamName .. ": BRG unlocked (GUI)", true)
elseif cmd == "SET_BRG" then
local brgVal = tonumber(parts[2])
local mode = parts[3] or "REL" -- backward compat with old hooks
if brgVal and brgVal >= 0 and brgVal < 360 then
j.bearingLocked = true
if mode == "ABS" and j.magDeclination then
-- Magnetic input -> true: add declination
j.lockedBearing = math.rad(brgVal + j.magDeclination)
else
-- Relative input -> true: add current heading
j.lockedBearing = j.heading + math.rad(brgVal)
end
while j.lockedBearing >= 2 * math.pi do j.lockedBearing = j.lockedBearing - 2 * math.pi end
while j.lockedBearing < 0 do j.lockedBearing = j.lockedBearing + 2 * math.pi end
inst:_Log(jamName .. ": BRG set " .. mode .. " " .. brgVal .. "° -> TRUE " ..
math.floor(math.deg(j.lockedBearing)) .. "° (GUI)", true)
end
elseif cmd == "CALIBRATE" then
local hdgDeg = math.floor(math.deg(j.heading) + 0.5) % 360
j.magDeclination = hdgDeg
inst:_Log(jamName .. ": MAG calibrated (declination=" .. hdgDeg .. "°) (GUI)", true)
elseif cmd == "SET_WIDE" then
local presetLabel = parts[2]
local found = false
for _, p in ipairs(AEGIS.WIDE_PRESETS) do
if p.label == presetLabel then
j.wideGain = p.gain
j.wideHalfAngleRad = p.angleRad
j.widePieHalfRad = p.pieHalfWidthRad
j.widePreset = p.label
found = true
break
end
end
if not found then return "ERR:unknown preset" end
inst:_Log(jamName .. ": WIDE preset -> " .. presetLabel .. " (GUI)", true)
else
return "ERR:unknown cmd"
end
-- Rebuild F10 menu so it stays in sync with GUI changes
inst:_CreateJammerF10Menu(jamName, j.groupId)
return "OK"
end
---------------------------------------------------------------------------
-- EA GUI Socket Listener (optional — requires require('socket') to be
-- available, i.e. MissionScripting.lua must NOT sanitize require/package).
-- If unavailable, silently no-ops. F10 menus always work regardless.
--
-- Protocol (plain ASCII over UDP, port 19410):
-- Client→Server REQ: state request
-- Server→Client S: state response
-- Client→Server CMD:: command
-- Server→Client R: command result
---------------------------------------------------------------------------
do
-- LuaSocket isn't on the mission env's default package.path.
-- DCS mission scripts run with cwd = DCS install dir, so relative paths work.
-- lfs is sanitized in mission env, so we can't use lfs.currentdir().
if package then
package.path = "LuaSocket/?.lua;" .. (package.path or "")
package.cpath = "bin/?.dll;" .. (package.cpath or "")
end
local ok, socket = pcall(require, "socket")
if not ok or not socket then
env.info("[AEGIS] EA socket: require('socket') not available — F10 menus only")
else
local EA_PORT = 19410
local MAX_RECV = 50
function AEGIS:_StartEASocket()
local udp = socket.udp()
if not udp then
self:_Log("EA socket: failed to create UDP socket", true)
return
end
local ok, err = udp:setsockname("*", EA_PORT)
if not ok then
self:_Log("EA socket: bind *:" .. EA_PORT .. " failed: " .. tostring(err), true)
udp:close()
return
end
udp:settimeout(0)
self._eaSocket = udp
self:_Log("EA socket: listening on UDP " .. EA_PORT)
-- Poll timer: check for incoming requests every 0.5s
local aegis = self
local function eaPoll()
if not aegis._eaSocket then return nil end -- stop if socket closed
local ok, err = pcall(function()
local sock = aegis._eaSocket
for _ = 1, MAX_RECV do
local data, srcIP, srcPort = sock:receivefrom()
if not data then break end
if data:sub(1, 4) == "REQ:" then
-- Format: REQ:playerName or REQ:playerName\0slot
-- Null byte delimiter (player names can contain tabs/pipes but not null)
local identity = data:sub(5)
local playerName, slot
local nulPos = identity:find("\0", 1, true)
if nulPos then
playerName = identity:sub(1, nulPos - 1)
slot = identity:sub(nulPos + 1)
else
playerName = identity
end
local state = AEGIS_EA_GET_STATE(playerName, slot)
if aegis.debug then
env.info("[AEGIS] EA REQ from " .. srcIP .. ":" .. srcPort .. " player='" .. playerName .. "' slot=" .. tostring(slot) .. " resp=" .. (#state > 0 and #state .. " chars" or "EMPTY"))
end
sock:sendto("S:" .. state, srcIP, srcPort)
elseif data == "DUMP" then
-- Full IADS state dump for companion visualizer
local parts = {}
-- SAMs: name;x;z;state;sysType;wez;actRange;jammed;sector
local samParts = {}
for name, sam in pairs(aegis.samSites) do
if sam.state ~= AEGIS.STATE.DESTROYED then
local x = sam.pos and math.floor(sam.pos.x) or 0
local z = sam.pos and math.floor(sam.pos.z) or 0
local wez = sam.sysData and sam.sysData.wez or 0
local act = sam.sysData and sam.sysData.actRange or 0
table.insert(samParts, name .. ";" .. x .. ";" .. z .. ";" .. sam.state
.. ";" .. (sam.sysType or "?") .. ";" .. wez .. ";" .. act
.. ";" .. (sam.jammed and "1" or "0") .. ";" .. (sam.sector or "?"))
end
end
-- EWs: name;x;z;sector;hasContacts;detRange
local ewParts = {}
for name, ew in pairs(aegis.ewRadars) do
if ew.state ~= AEGIS.STATE.DESTROYED then
local x = ew.pos and math.floor(ew.pos.x) or 0
local z = ew.pos and math.floor(ew.pos.z) or 0
table.insert(ewParts, name .. ";" .. x .. ";" .. z .. ";" .. (ew.sector or "?")
.. ";" .. (ew.hasContacts and "1" or "0") .. ";" .. (ew.detRange or 0))
end
end
-- Jammers: name;x;z;mode;heading;active;jamType;effectRange;p1Target;p2Target;bearingLocked;lockedBearing;wideHalfAngle
local jamParts = {}
for name, jam in pairs(aegis.jammers) do
if jam.alive then
local x = jam.pos and math.floor(jam.pos.x) or 0
local z = jam.pos and math.floor(jam.pos.z) or 0
local hdg = jam.heading and math.floor(math.deg(jam.heading)) or 0
local bl = AEGIS.JAMMER_BASELINE
local efr = bl.effectRange
local wideHA = jam.wideHalfAngleRad and math.floor(math.deg(jam.wideHalfAngleRad)) or bl.wideHalfAngle
table.insert(jamParts, name .. ";" .. x .. ";" .. z .. ";" .. (jam.mode or "OFF")
.. ";" .. hdg .. ";" .. (jam.active and "1" or "0") .. ";" .. (jam.jamType or "?")
.. ";" .. efr .. ";" .. (jam.pod1Target or "") .. ";" .. (jam.pod2Target or "")
.. ";" .. (jam.bearingLocked and "1" or "0")
.. ";" .. (jam.lockedBearing and math.floor(math.deg(jam.lockedBearing)) or 0)
.. ";" .. wideHA)
end
end
-- Sectors: name;jammed;jamBearing
local secParts = {}
for name, sec in pairs(aegis.sectors) do
secParts[#secParts+1] = name .. ";" .. (sec.jammed and "1" or "0")
.. ";" .. math.floor(math.deg(sec.jamBearing or 0))
end
local resp = "D:" .. table.concat(samParts, "|")
.. "\n" .. table.concat(ewParts, "|")
.. "\n" .. table.concat(jamParts, "|")
.. "\n" .. table.concat(secParts, "|")
sock:sendto(resp, srcIP, srcPort)
elseif data:sub(1, 4) == "CMD:" then
-- Format: CMD:playerName\0slot:cmdStr or CMD:playerName:cmdStr
-- Null byte separates playerName from slot; first colon after slot = cmdStr
local payload = data:sub(5)
local playerName, slot, cmdStr
local nulPos = payload:find("\0", 1, true)
if nulPos then
playerName = payload:sub(1, nulPos - 1)
local rest = payload:sub(nulPos + 1)
local colonPos = rest:find(":", 1, true)
if colonPos then
slot = rest:sub(1, colonPos - 1)
cmdStr = rest:sub(colonPos + 1)
end
else
-- No null = old format: playerName:cmdStr (first colon splits)
local colonPos = payload:find(":", 1, true)
if colonPos then
playerName = payload:sub(1, colonPos - 1)
cmdStr = payload:sub(colonPos + 1)
end
end
if playerName and cmdStr then
local result = AEGIS_EA_CMD(playerName, cmdStr, slot)
sock:sendto("R:" .. result, srcIP, srcPort)
else
sock:sendto("R:ERR:malformed CMD", srcIP, srcPort)
end
end
end
end)
if not ok then
env.info("[AEGIS!] EA poll error: " .. tostring(err))
end
return timer.getTime() + 0.5
end
timer.scheduleFunction(eaPoll, nil, timer.getTime() + 1)
end
function AEGIS:_StopEASocket()
if self._eaSocket then
self._eaSocket:close()
self._eaSocket = nil
self:_Log("EA socket: closed")
end
end
end
end