[Q] Air intercept against Non hostile Bogeys

Take command of air and naval assets from post-WW2 to the near future in tactical and operational scale, complete with historical and hypothetical scenarios and an integrated scenario editor.

Moderator: MOD_Command

Post Reply
Knightpawn
Posts: 781
Joined: Mon Dec 02, 2024 12:28 pm

[Q] Air intercept against Non hostile Bogeys

Post by Knightpawn »

Is the behavior of an intercepted non-hostile aircraft (eg. an unfriendly) affected as a result of the intercept? Will it turn around and go away (assuming that it is not fired upon) or will it continue to its mission / path?
User avatar
SunlitZelkova
Posts: 426
Joined: Tue Mar 06, 2018 11:49 pm
Location: Portland, USA

Re: [Q] Air intercept against Non hostile Bogeys

Post by SunlitZelkova »

I think it does not change. But making it do so with Lua sounds feasible. The aircraft would be ordered to RTB or switch to a different mission (example: have one CAP set up in the "no-fly zone" where interceptions will take place, and then a CAP mission set up in a "safe zone") once the intercepting aircraft or any aircraft from that side approach within a certain distance.

I was thinking this could be done with relative waypoints around the planes but then I realized they would disappear on landing and not exist on takeoff. I'm not proficient enough in Lua to know if using it to apply waypoints or setting up the "trigger radius" purely through the script would be easier.
"One must not consider the individual objects without the whole."- Generalleutnant Gerhard von Scharnhorst, Royal Prussian Army
Knightpawn
Posts: 781
Joined: Mon Dec 02, 2024 12:28 pm

Re: [Q] Air intercept against Non hostile Bogeys

Post by Knightpawn »

This is a Claude approach to the problem formulated by myself in 'Rule Implemented' for anyone amongst us with better knowledge to audit (problem formulation, code and whether the 15secs checks creates slowdowns).

updated 17 Aug 26

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
Post Reply

Return to “Command: Modern Operations series”