Lua Script to RTB intercepted bogeys

All discussions & material related to Command's Lua interface

Moderators: RoryAndersonCDT, michaelm75au, angster, MOD_Command

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

Lua Script to RTB intercepted bogeys

Post by Knightpawn »

I vibe coded this script with Claude to introduce a behavior whereby non player bogeys when "cold" intercepted (i.e. not fired upon) go RTB. This works with 'Unfriendly" sides (hostiles will be fired upon regardless). I hope you find it handy

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 “Lua Legion”