Code: Select all
-- =====================================================================
-- INTERCEPTED AIRCRAFT -> RTB (MULTI-SIDE, SMOOTHED-LOAD VERSION)
-- Command: Modern Operations Lua script (Lua 5.3)
-- =====================================================================
-- PURPOSE:
-- Regulates the behaviour of COMPUTER-side aircraft when they are
-- intercepted by the player. SIDES_A below are the NON-PLAYER
-- (computer) sides. When one of their aircraft ('AC A') is
-- intercepted, it gives up and returns to base, and the player is
-- told the intercept succeeded.
--
-- RULE:
-- If an AC A is approached closer than TRIGGER_RANGE_NM (1 nm) by a
-- fighter or multirole aircraft of any side that is NOT (a) one of
-- the computer sides, (b) friendly, or (c) neutral to that computer
-- side (an interceptor - the 'Bogey' through the computer side's
-- eyes, i.e. normally the player), and the interceptor stays within
-- HOLD_RANGE_NM (5 nm) for HOLD_TIME_SECS (120 s), then:
-- * AC A is ordered to RTB, and
-- * MSG_INTERCEPT ("Bogey successfully intercepted") is shown to
-- the INTERCEPTOR'S side (the player).
-- RE-ARM: an AC A ordered RTB is exempt until it has recovered
-- (landed); the rule then applies afresh on its next sortie.
--
-- LOAD SMOOTHING (v3 - removes the periodic stutter at max compression):
-- Earlier versions did all work in one firing every ~60 s, causing a
-- visible pause. Work is now spread so NO single firing exceeds a
-- fixed API-call budget:
-- 1. ROSTER VIA unitsBy: aircraft lists come from one
-- side:unitsBy('Aircraft') call per side instead of a
-- GetUnit sweep over every unit of every side. (Falls back to
-- the full sweep if unitsBy is unavailable.)
-- 2. PERMANENT SUBTYPE CACHE: each interceptor-side aircraft is
-- classified fighter/non-fighter ONCE, ever; non-fighters are
-- never fetched again.
-- 3. BUDGETED SWEEP: the periodic position sweep fetches at most
-- WORK_BUDGET units per firing, continuing across consecutive
-- firings until complete, then schedules the next sweep from
-- worst-case closure speed (staleness compensated).
-- 4. AIRBORNE-ONLY SWEEPS: aircraft last seen on the ground are
-- re-checked only every GROUND_RECHECK_SECS, so steady-state
-- sweeps touch airborne aircraft only (parked fleets cost
-- nothing; a new launch is noticed within the recheck interval).
-- 5. HOT-PAIR FAST PATH: pairs that are armed or inside HOT_RANGE_NM
-- are re-checked every firing (a handful of fetches), so rule
-- timing accuracy is unchanged during actual engagements.
-- 6. Key store written only on state change (save/load persistence).
--
-- HOW TO INSTALL (Event Editor):
-- 1. Trigger: "Regular Time", interval = 15 seconds.
-- 2. Action: "Lua Script" containing this entire file.
-- 3. Event: repeatable, with the above trigger + action.
--
-- TUNING:
-- * WORK_BUDGET caps per-firing API load; lower it if any residual
-- stutter remains (sweeps just take more firings to complete).
-- * Reaction latency while quiet is up to MAX_SLEEP_SECS plus the
-- sweep duration; engaged timing accuracy = trigger interval.
-- * Uses ground truth (positions, postures), not sensor pictures.
-- =====================================================================
-- ------------------------- RULE CONFIGURATION ------------------------
local SIDES_A = { 'Angola', 'Russia' } -- <<< EDIT: the NON-PLAYER
-- (computer) sides regulated
-- by this script
local TRIGGER_RANGE_NM = 1.0 -- initial approach distance (nm)
local HOLD_RANGE_NM = 5.0 -- interceptor stays inside this (nm)
local HOLD_TIME_SECS = 120 -- ... for this long (seconds)
local MIN_AIRBORNE_KTS = 40 -- above this speed = airborne
local KEY_PREFIX = 'BOGEYRTB_' -- key-store prefix
local MSG_INTERCEPT = 'Bogey successfully intercepted'
-- Aircraft subtype codes counted as interceptors
-- (per Command Lua DataTypes: 2001 = Fighter, 2002 = Multirole)
local INTERCEPTOR_SUBTYPES = { ['2001'] = true, ['2002'] = true }
-- --------------------- PERFORMANCE CONFIGURATION ---------------------
local ROSTER_REFRESH_SECS = 120 -- re-query aircraft lists/postures
local HOT_RANGE_NM = 15 -- inside this: check every firing
local MAX_CLOSURE_KTS = 3000 -- worst-case combined closing speed
local MAX_SLEEP_SECS = 60 -- max gap between position sweeps
local MIN_SLEEP_SECS = 15 -- match the trigger interval
local WORK_BUDGET = 25 -- max unit fetches per firing for the
-- background sweep (the anti-stutter
-- knob: lower = smoother, slower)
local GROUND_RECHECK_SECS = 60 -- aircraft last seen on the ground
-- are re-checked only this often, so
-- sweeps effectively touch airborne
-- aircraft only (a new launch is
-- noticed within this interval)
-- ---------------------------------------------------------------------
local now = ScenEdit_CurrentTime()
-- Session-persistent state (survives between event firings within a
-- play session; rebuilt from the key store after a save/load).
if BOGEYRTB_STATE == nil then
BOGEYRTB_STATE = {
nextDue = 0, -- next scenario time a sweep is due
rosterAt = -1e12, -- last roster build time
acA = {}, -- { {guid=, side=}, ... } computer aircraft
acSide = {}, -- [guid] = side name (for messages)
intA = {}, -- { {guid=, watchers={side=true}}, ... }
-- interceptor-side aircraft (all types)
fighter = {}, -- [guid] = true/false, classified once ever
done = {}, -- [acGuid] = true (RTB'd this sortie)
armed = {}, -- [acGuid..'|'..intGuid] = armedAt time
hot = {}, -- [pid] = true: check this pair every firing
groundedAt = {}, -- [guid] = time last seen on the ground
sweep = nil, -- in-progress budgeted sweep, or nil
restored = false, -- key-store state re-read yet?
}
end
local S = BOGEYRTB_STATE
-- --------------------------- helpers ---------------------------------
local function isComputerSide(name)
for _, p in ipairs(SIDES_A) do
if name == p then return true end
end
return false
end
local function isAirborne(u)
return u.speed ~= nil and u.speed > MIN_AIRBORNE_KTS
end
-- Equirectangular approximation, result in nautical miles
local function approxRangeNM(lat1, lon1, lat2, lon2)
local dLat = lat2 - lat1
local dLon = lon2 - lon1
if dLon > 180 then dLon = dLon - 360
elseif dLon < -180 then dLon = dLon + 360 end
local mid = math.rad((lat1 + lat2) / 2)
local x = dLon * 60 * math.cos(mid)
local y = dLat * 60
return math.sqrt(x * x + y * y)
end
-- Core rule for one AC A / interceptor pair. Both wrappers airborne.
-- Returns rtbOrdered(bool), distance(nm).
local function processPair(acU, acSideName, intU, aLat, aLon, bLat, bLon)
local pid = acU.guid .. '|' .. intU.guid
local pairKey = KEY_PREFIX .. acU.guid .. '_' .. intU.guid
local d = approxRangeNM(aLat, aLon, bLat, bLon)
local armedAt = S.armed[pid]
if armedAt == nil then
if d < TRIGGER_RANGE_NM then
S.armed[pid] = now
S.hot[pid] = true
ScenEdit_SetKeyValue(pairKey, tostring(now))
print(string.format(
'BOGEYRTB: %s (%s) approached < %.1f nm by %s - timer started',
acU.name, acSideName, TRIGGER_RANGE_NM, intU.name))
elseif d <= HOT_RANGE_NM then
S.hot[pid] = true
else
S.hot[pid] = nil
end
else
if d > HOLD_RANGE_NM then
-- Interceptor broke off: reset the timer
S.armed[pid] = nil
ScenEdit_ClearKeyValue(pairKey)
if d > HOT_RANGE_NM then S.hot[pid] = nil end
elseif (now - armedAt) >= HOLD_TIME_SECS then
-- INTERCEPT SUCCESSFUL:
-- 1) computer aircraft gives up and goes home
ScenEdit_SetUnit({ guid = acU.guid, rtb = true })
S.done[acU.guid] = true
ScenEdit_SetKeyValue(KEY_PREFIX .. 'DONE_' .. acU.guid, '1')
S.armed[pid] = nil
S.hot[pid] = nil
ScenEdit_ClearKeyValue(pairKey)
-- 2) tell the INTERCEPTOR'S side (the player)
ScenEdit_SpecialMessage(intU.side,
MSG_INTERCEPT .. ' (' .. acU.name
.. ' is returning to base)')
print('BOGEYRTB: ' .. MSG_INTERCEPT .. ' - ' .. acU.name
.. ' (' .. acSideName .. ') ordered RTB; interceptor: '
.. intU.name)
return true, d
else
S.hot[pid] = true
end
end
return false, d
end
local function clearPair(pid, acGuid, intGuid)
S.hot[pid] = nil
if S.armed[pid] ~= nil then
S.armed[pid] = nil
ScenEdit_ClearKeyValue(KEY_PREFIX .. acGuid .. '_' .. intGuid)
end
end
-- ============== 1. HOT-PAIR FAST PATH (every firing) =================
if next(S.hot) ~= nil then
local cache = {}
local function fetch(g)
local c = cache[g]
if c == nil then
c = ScenEdit_GetUnit({ guid = g }) or false
cache[g] = c
end
if c == false then return nil end
return c
end
for pid in pairs(S.hot) do
local acG, inG = string.match(pid, '^(.+)|(.+)$')
if S.done[acG] then
S.hot[pid] = nil
else
local au = fetch(acG)
local iu = fetch(inG)
if au == nil or iu == nil
or not isAirborne(au) or not isAirborne(iu) then
clearPair(pid, acG, inG) -- dead, landed or grounded
else
processPair(au, S.acSide[acG] or au.side, iu,
tonumber(au.latitude), tonumber(au.longitude),
tonumber(iu.latitude), tonumber(iu.longitude))
end
end
end
end
-- ================ 2. START A SWEEP WHEN DUE ==========================
if S.sweep == nil and now >= S.nextDue then
-- Roster refresh (cheap: one unitsBy call per side)
if (now - S.rosterAt) >= ROSTER_REFRESH_SECS then
S.rosterAt = now
S.acA, S.acSide = {}, {}
for _, p in ipairs(SIDES_A) do
local side = VP_GetSide({ side = p })
if side == nil then
print('BOGEYRTB: side "' .. p .. '" not found - check SIDES_A')
else
local list = side:unitsBy('Aircraft')
if list ~= nil then
for i = 1, #list do
table.insert(S.acA, { guid = list[i].guid, side = p })
S.acSide[list[i].guid] = p
end
else -- fallback: full unit sweep (one-off cost)
for i = 1, #side.units do
local u = ScenEdit_GetUnit({ guid = side.units[i].guid })
if u ~= nil and u.type == 'Aircraft' then
table.insert(S.acA, { guid = u.guid, side = p })
S.acSide[u.guid] = p
end
end
end
end
end
S.intA = {}
for _, sd in ipairs(VP_GetSides()) do
if not isComputerSide(sd.name) then
local watchers, any = {}, false
for _, p in ipairs(SIDES_A) do
local posture = ScenEdit_GetSidePosture(p, sd.name)
if posture ~= 'F' and posture ~= 'N' then
watchers[p] = true
any = true
end
end
if any then
local side = VP_GetSide({ side = sd.name })
if side ~= nil then
local list = side:unitsBy('Aircraft')
if list ~= nil then
for i = 1, #list do
table.insert(S.intA,
{ guid = list[i].guid, watchers = watchers })
end
else -- fallback
for i = 1, #side.units do
local u = ScenEdit_GetUnit({ guid = side.units[i].guid })
if u ~= nil and u.type == 'Aircraft' then
table.insert(S.intA,
{ guid = u.guid, watchers = watchers })
end
end
end
end
end
end
end
-- One-time restore of persisted state (session start / load)
if not S.restored then
S.restored = true
for _, a in ipairs(S.acA) do
if ScenEdit_GetKeyValue(KEY_PREFIX .. 'DONE_' .. a.guid) ~= '' then
S.done[a.guid] = true
end
for _, b in ipairs(S.intA) do
local pv = ScenEdit_GetKeyValue(
KEY_PREFIX .. a.guid .. '_' .. b.guid)
if pv ~= '' then
local pid = a.guid .. '|' .. b.guid
S.armed[pid] = tonumber(pv)
S.hot[pid] = true
end
end
end
end
end
S.sweep = { startT = now, ai = 1, ii = 1, acPos = {}, intPos = {} }
end
-- ============ 3. BUDGETED SWEEP CHUNK (max WORK_BUDGET) ==============
if S.sweep ~= nil then
local sw = S.sweep
local budget = WORK_BUDGET
while budget > 0 and sw.ai <= #S.acA do
local a = S.acA[sw.ai]
sw.ai = sw.ai + 1
-- Aircraft recently seen on the ground: skip without fetching
local gAt = S.groundedAt[a.guid]
if gAt == nil or (now - gAt) >= GROUND_RECHECK_SECS then
budget = budget - 1
local u = ScenEdit_GetUnit({ guid = a.guid })
if u ~= nil then
if isAirborne(u) then
S.groundedAt[a.guid] = nil
table.insert(sw.acPos, { u = u, side = a.side,
lat = tonumber(u.latitude),
lon = tonumber(u.longitude) })
else
S.groundedAt[a.guid] = now
-- RE-ARM RULE: landed -> clear DONE flag + timers
if S.done[u.guid] then
S.done[u.guid] = nil
ScenEdit_ClearKeyValue(KEY_PREFIX .. 'DONE_' .. u.guid)
print('BOGEYRTB: ' .. u.name
.. ' has recovered - rule re-armed')
end
local pref = u.guid .. '|'
for pid in pairs(S.armed) do
if string.sub(pid, 1, #pref) == pref then
local intGuid = string.sub(pid, #pref + 1)
clearPair(pid, u.guid, intGuid)
end
end
end
end
end
end
while budget > 0 and sw.ii <= #S.intA do
local b = S.intA[sw.ii]
sw.ii = sw.ii + 1
local gAt = S.groundedAt[b.guid]
if S.fighter[b.guid] ~= false -- skip known non-fighters
and (gAt == nil or (now - gAt) >= GROUND_RECHECK_SECS) then
budget = budget - 1
local u = ScenEdit_GetUnit({ guid = b.guid })
if u ~= nil then
if S.fighter[b.guid] == nil then -- classify once, ever
S.fighter[b.guid] =
(INTERCEPTOR_SUBTYPES[tostring(u.subtype)] == true)
end
if S.fighter[b.guid] then
if isAirborne(u) then
S.groundedAt[b.guid] = nil
table.insert(sw.intPos,
{ u = u, watchers = b.watchers,
lat = tonumber(u.latitude),
lon = tonumber(u.longitude) })
else
S.groundedAt[b.guid] = now
end
end
end
end
end
-- ================ 4. SWEEP COMPLETE: pair logic ==================
if sw.ai > #S.acA and sw.ii > #S.intA then
S.sweep = nil
local minGap = nil
-- acPos/intPos contain airborne aircraft only (grounded ones
-- were filtered - and handled - at fetch time above)
for _, a in ipairs(sw.acPos) do
local ac = a.u
if not S.done[ac.guid] then
for _, b in ipairs(sw.intPos) do
if b.watchers[a.side] then
local rtbd, d = processPair(
ac, a.side, b.u, a.lat, a.lon, b.lat, b.lon)
if minGap == nil or d < minGap then minGap = d end
if rtbd then break end
end
end
end
end
-- ================ 5. SCHEDULE NEXT SWEEP =====================
local sleep
if next(S.hot) ~= nil then
sleep = MIN_SLEEP_SECS -- engaged: sweep continuously
elseif minGap == nil then
sleep = MAX_SLEEP_SECS -- no airborne pairs at all
else
-- Nothing can reach HOT_RANGE before the gap closes at
-- worst-case speed; compensate for sweep duration.
local closure = MAX_CLOSURE_KTS / 3600
local safe = (minGap - HOT_RANGE_NM) / closure
- (now - sw.startT)
sleep = math.max(MIN_SLEEP_SECS,
math.min(MAX_SLEEP_SECS, safe))
end
S.nextDue = now + sleep
end
end