Files
Applevangelist 257801edca xx
2026-04-16 11:19:21 +02:00

1347 lines
54 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- **Functional** - Enhanced Warsaw Pact GCI Controller.
--
-- ## Main Features:
--
-- * Guide AI and human pilots in Warsaw Pact Style. GCI Dispatcher.
-- * Advanced Tactics for Groups.
-- * Many additional events that the mission designer can hook into.
--
-- ===
--
-- ### Author: **Applevangelist**
--
-- ===
-- @module Functional.REDGCIDISPATCHER
-- @image Func_RedGCI.png
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--- **REDGCIDISPATCHER** class, extends Core.Fsm#FSM class
-- @type REDGCIDISPATCHER
-- @field #string ClassName
-- @field #string version
-- @extends Core.Fsm#FSM
---
-- # RedGCI — Soviet GCI Doctrine & Player Guide
--
-- ## Philosophy: Централизованное управление (Centralized Control)
--
-- The fundamental difference between Soviet and NATO GCI is **who makes the tactical decision**.
--
-- In NATO doctrine, the GCI controller provides situational awareness — bearing, range, altitude, aspect — and the pilot decides how to prosecute the intercept. The pilot is an autonomous tactician. GCI is an advisor.
--
-- In Soviet doctrine, the GCI controller **directs**. The pilot executes. The controller selects the intercept geometry, assigns the heading, manages the radar, calls weapons free, and coordinates multi-ship tactics. The pilot's job is to fly the numbers and shoot when told. This is not a flaw — it is the system working as designed. Soviet fighter pilots were trained to be precise executors of GCI instructions, not independent tacticians. The ground radar network (PVO) was the brain; the aircraft was the weapon.
--
-- RedGCI models this philosophy faithfully.
--
-- ---
--
-- ## What to Expect as a Player
--
-- ### You will not be asked what you want to do.
--
-- There are no "recommend a vector" calls, no "at your discretion" callouts. The controller tells you your heading, your altitude, and your task. Your acknowledgement is assumed.
--
-- ### The controller manages your radar.
--
-- You do not decide when to turn your radar on. The GCI will tell you when to switch on (`локатор` / `Radar on`). Before that call, you fly cold and silent. This preserves your emissions discipline and prevents the target from getting an early RWR spike.
--
-- ### Weapons free is a controlled event.
--
-- You do not engage until the controller clears you (`цель разрешена` / `WEAPONS FREE`). The controller determines when geometry, range, and aspect are favorable. Shooting early breaks the coordinated intercept and may compromise your wingman's attack.
--
-- ### Radio calls are short and military.
--
-- Soviet GCI brevity is terse by design. Expect calls like:
--
-- - `"Сокол, курс 170, высота 4500."` — vector, altitude
-- - `"Сокол, цель, пара, истребитель. Локатор."` — picture call on commit: count, type, radar on
-- - `"Сокол, захват. Дальность 20. Цель разрешена."` — lock confirmed, range, weapons free
-- - `"Сокол, молодец. Домой."` — good kill, RTB
--
-- There are no "BOGEY DOPE" requests, no "BRAA" calls, no "DECLARE" queries. The controller has already done that work. You fly the vector.
--
-- ---
--
-- ## State Flow — What the GCI is Doing Behind the Scenes
--
-- RedGCI manages a state machine that progresses through six phases. Understanding these phases helps you anticipate what call is coming next.
--
-- ```
-- VECTOR → COMMIT → RADAR_CONTACT → VISUAL → MERGE → (SPLASH / ABORT / RTB)
-- ```
--
-- ### VECTOR
-- The controller has a track. You are being vectored onto an intercept geometry. Your radar is off. The controller is solving a collision course and updating your heading every tick. Altitude calls reflect the intercept geometry — you may be sent below the target (classic Soviet shoot-up doctrine for radar-limited types) or level/above (MiG-29/Su-27 lookdown geometry). Expect heading updates every 1015 seconds.
--
-- **What you should do:** Fly the heading. Don't deviate. Don't turn your radar on yet. Speed is expected at 900kph TAS (depending on airframe)
--
-- ### COMMIT
-- Range has closed to approximately 30km. The controller calls the picture: count and type. Your radar comes on. You are now committed to the intercept — turning away is no longer the default option. The controller is building your radar geometry toward a lock.
--
-- **What you should do:** Activate your radar. Acquire the target. Do not fire yet.
--
-- ### RADAR_CONTACT
-- You have radar lock (or the AI has achieved it). The controller confirms lock and calls range. If geometry and range are favorable, weapons free follows immediately. If not — for example if aspect angle is unfavorable for a stern conversion — the controller holds fire and waits for better geometry.
--
-- **What you should do:** Maintain lock. Track the target. Wait for the weapons free call.
--
-- ### VISUAL
-- Range has closed to approximately 5km — visual conditions. Weapons free is automatic at this point. You are now in the merge envelope.
--
-- **What you should do:** Engage.
--
-- ### MERGE
-- Inside 2km. The GCI transitions to merge control: bearing to target, overshoot calls, separation instructions, reattack vectors. At this range the controller cannot see fine-grained geometry — merge calls are based on relative bearing and closure.
--
-- **What you should do:** Fight. Listen for overshoot, separation, and reattack calls.
--
-- ### SPLASH / ABORT / RTB
-- - `SPLASH` — kill confirmed, RTB
-- - `ABORT (THREAT)` — your RWR is spiked or a threat geometry has developed; break off immediately on the given heading
-- - `ABORT (BINGO)` — fuel state critical; break off and return
--
-- ---
--
-- ## Multi-Ship (2v2) Tactics (REDGCI2v2)
--
-- When two fighters are dispatched against a threat, the GCI selects a tactic automatically based on the tactical situation. The tactic is applied at COMMIT — until then, both fighters are vectored together toward the intercept midpoint.
--
-- | Tactic | Description |
-- |--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
-- | **PINCER** | Classic bracket. F1 and F2 split left and right, attacking from opposite angles simultaneously. Forces the target to choose which threat to react to. |
-- | **HIGH-LOW** | Vertical split. One fighter attacks from below (radar up, clean sky background), one from above. Degrades the target's ability to acquire both simultaneously. |
-- | **STAGGER** | BVR timing offset. F1 fires first at long range, F2 follows 813km behind to engage a maneuvering or defending target. |
-- | **TRAIL** | Close trail. F1 is the shooter, F2 is support — ready to engage if F1 overshoots or is defeated. |
-- | **GIRAFFE** | *(Historical — Iraq/Iran War, Mirage F1 vs F-14A)* F1 attacks at normal altitude, binding the AWG-9 radar. F2 flies nap-of-earth |
-- | | (300600m AGL) using ground clutter to degrade radar detection, then pulls up and fires from close range. |
--
-- During a tactic split, you may receive a heading that seems unusual — a large lateral offset or an unexpected altitude change. **Trust the vector.** The controller is positioning you for the tactic geometry. The merge point will bring you back onto the target.
--
-- ---
--
-- ## Dispatcher & CAP Flow (REDGCIDISPATCHER)
--
-- When using the dispatcher layer, the full operational flow is:
--
-- ```
-- Spawn at homeplate
-- → Taxi and takeoff (template-controlled)
-- → Transit to CAP zone
-- → Orbit in assigned zone (radar cold, weapons safe)
-- ↓ INTEL detects threat cluster
-- → "Attention, radar contact. Pair, fighter, 45 kilometers." (all CAP fighters)
-- ↓ Dispatcher assigns pair
-- → "101 102, intercept. Pair, fighter." (dispatched pair)
-- → VECTOR → COMMIT → RADAR_CONTACT → VISUAL → MERGE → SPLASH
-- ↓ Engagement complete
-- → AI: RTB waypoint → land → despawn → respawn after delay
-- → Human: "101, mission complete. RTB, refuel and rearm."
-- ↓ After RespawnDelay
-- → New AI pair spawns into same CAP zone
-- ```
--
-- Human players are dispatched first when available. If a human and AI are both in the CAP pool, the human is always assigned to the next intercept. AI fills gaps. The dispatcher does not send a single fighter if a pair is available — pairing is always preferred.
--
-- ---
--
-- ## Key Differences from NATO GCI at a Glance
--
-- | | Soviet (RedGCI) | NATO |
-- |--------------------|--------------------------------------|------------------------------------|
-- | Tactical decision | Controller | Pilot |
-- | Radar management | Controller-commanded | Pilot-initiated |
-- | Weapons free | Controller-called | Pilot-discretion (after WF) |
-- | Heading calls | Prescriptive | Advisory |
-- | Brevity style | Terse, military, positional | Standardized (BRAA, DECLARE, etc.) |
-- | Multi-ship tactics | Centrally planned, applied at COMMIT | Mutually briefed, pilot-executed |
-- | Pilot autonomy | Low (by design) | High |
--
-- **The Soviet system is not inferior** — it is optimized for a different kind of pilot and a different operational context. Mass interception of large NATO strike packages over defended Soviet airspace demanded centralized, efficient, high-throughput GCI control. RedGCI brings that experience to DCS.
--
-- @field #REDGCIDISPATCHER
REDGCIDISPATCHER = {
ClassName = "REDGCIDISPATCHER",
version = "2.0.0",
}
-- ─────────────────────────────────────────────────────────────
-- Constants
-- ─────────────────────────────────────────────────────────────
---
-- @field #number TICK_INTERVAL
-- @field #number UNITS_PER_PAIR
-- @field #number ORBIT_SPEED_KMPH
-- @field #number ORBIT_ALT_M
-- @field #number AI_PER_ZONE
-- @field #string STATE_CAP
-- @field #string STATE_ENGAGED
-- @field #string STATE_RTB
-- @field #string STATE_UNKNOWN
REDGCIDISPATCHER.TICK_INTERVAL = 30.0
REDGCIDISPATCHER.UNITS_PER_PAIR = 2
REDGCIDISPATCHER.ORBIT_SPEED_KMPH = 600
REDGCIDISPATCHER.ORBIT_ALT_M = 4500
REDGCIDISPATCHER.AI_PER_ZONE = 2
REDGCIDISPATCHER.STATE_CAP = "CAP"
REDGCIDISPATCHER.STATE_ENGAGED = "ENGAGED"
REDGCIDISPATCHER.STATE_RTB = "RTB"
REDGCIDISPATCHER.STATE_UNKNOWN = "UNKNOWN"
-- ─────────────────────────────────────────────────────────────
-- Localized messages (token system, analogous to REDGCI)
-- ─────────────────────────────────────────────────────────────
---
-- @type Messages
REDGCIDISPATCHER.Messages = {
en = {
RTB_CALL = "{CALLSIGN}, mission complete. RTB, refuel and rearm.",
INTEL_CONTACT = "Attention, radar contact. {COUNT}, {TYPE}, {RNG} kilometers.",
DISPATCH_CALL = "{CALLSIGN}, intercept. {COUNT}, {TYPE}.",
},
de = {
RTB_CALL = "{CALLSIGN}, Einsatz beendet. Heimkurs, tanken, bewaffnen.",
INTEL_CONTACT = "Achtung, Radar Kontakt. {COUNT}, {TYPE}, {RNG} Kilometer.",
DISPATCH_CALL = "{CALLSIGN}, abfangen. {COUNT}, {TYPE}.",
},
ru = {
RTB_CALL = "{CALLSIGN}, задание выполнено. Домой, дозаправка, перевооружение.",
INTEL_CONTACT = "Внимание, локатор обнаружил цель. {COUNT}, {TYPE}, дальность {RNG}.",
DISPATCH_CALL = "{CALLSIGN}, на перехват. {COUNT}, {TYPE}.",
},
}
---
--@field #table RadioChannels
REDGCIDISPATCHER.RadioChannels = {
[1] = 125,
[2] = 125.5,
[3] = 126,
[4] = 126.5,
[5] = 127,
[6] = 127.5,
[7] = 128,
[8] = 128.5,
[9] = 129,
[10] = 129.5,
}
-- ─────────────────────────────────────────────────────────────
-- Constructor
-- ─────────────────────────────────────────────────────────────
--- Create a new REDGCIDISPATCHER instance.
-- @param #REDGCIDISPATCHER self
-- @param #string TemplateName Name of the late-activated template group (1 unit)
-- @param Core.Set#SET_ZONE ZoneSet SET_ZONE mit CAP-Holding-Zonen
-- @param Ops.Intel#INTEL Intel Laufende INTEL-Instanz
-- @param #number Coalition coalition.side.RED / BLUE
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:New(TemplateName, ZoneSet, Intel, Coalition)
local self = BASE:Inherit(self, FSM:New())
self.lid = "REDGCIDISPATCHER | "
self.TemplateName = TemplateName
self.ZoneSet = ZoneSet
self.Intel = Intel
self.Coalition = Coalition or coalition.side.RED
self.HomeBaseName = nil
self.HomeBase = nil
self.Locale = "ru"
self.Debug = true
self.AltOffset = -700
self.WFRange = 20000
self.OrbitAlt = REDGCIDISPATCHER.ORBIT_ALT_M
self.OrbitSpeed = REDGCIDISPATCHER.ORBIT_SPEED_KMPH
self.AiPerZone = REDGCIDISPATCHER.AI_PER_ZONE
self.StartCallsign = 100
self.SRSPath = nil
self.SRSFreq = 251
self.SRSMod = radio.modulation.AM
self.SRSCulture = "ru-RU"
self.SRSVoice = MSRS.Voices.Google.Standard.ru_RU_Standard_D
self.SRSPort = 5002
self.SRSSpeed = 1
self.SRSProvider = nil
self.SRSFreqPilotsStart = self.SRSFreq + 1
self.ClientSet = nil
-- Respawn after engagement
self.RespawnEnabled = true
self.RespawnDelay = 300 -- Seconds after RTB before new AI are spawned (default 5 min)
self.RespawnCount = 2 -- Number of new AI per respawn
self._pool = {}
self._engagements = {}
self._known_clusters = {} -- clusterKey -> true, tracks known vs. new clusters
self._callsign_counter = self.StartCallsign
self._msrs = nil
self._srs_queue = nil
self._gettext = nil
self:SetStartState("Stopped")
self:AddTransition("Stopped", "Start", "Running")
self:AddTransition("Running", "Status", "Running")
self:AddTransition("Running", "Stop", "Stopped")
self:I(self.lid .. "v" .. REDGCIDISPATCHER.version .. " created.")
return self
end
-- ─────────────────────────────────────────────────────────────
-- User API
-- ─────────────────────────────────────────────────────────────
--- Set the home plate.
-- @param #REDGCIDISPATCHER self
-- @param #string BaseName
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetHomeBase(BaseName)
self.HomeBaseName = BaseName
local ab = AIRBASE:FindByName(BaseName)
if ab then self.HomeBase = ab
else self:E(self.lid .. "SetHomeBase: '" .. tostring(BaseName) .. "' not found!") end
return self
end
--- Set the client SET.
-- @param #REDGCIDISPATCHER self
-- @param Core.Set#SET_CLIENT ClientSet
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetClientSet(ClientSet)
self.ClientSet = ClientSet
return self
end
--- Set orbit altitude and speed.
-- @param #REDGCIDISPATCHER self
-- @param #number AltMSL Altitude MSL in meters (default 4500)
-- @param #number SpeedKmph Speed in kph (km/h) (default 600)
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetOrbitParameters(AltMSL, SpeedKmph)
self.OrbitAlt = AltMSL or REDGCIDISPATCHER.ORBIT_ALT_M
self.OrbitSpeed = SpeedKmph or REDGCIDISPATCHER.ORBIT_SPEED_KMPH
return self
end
--- Set number of AI spawned per CAP zone.
-- @param #REDGCIDISPATCHER self
-- @param #number N Default 2
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetAiPerZone(N)
self.AiPerZone = N or 2
return self
end
--- Set starting callsign number.
-- @param #REDGCIDISPATCHER self
-- @param #number N Default 100
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetStartCallsign(N)
self.StartCallsign = N or 100
self._callsign_counter = self.StartCallsign
return self
end
--- Set locale for radio messages.
-- @param #REDGCIDISPATCHER self
-- @param #string Locale "ru", "de", "en"
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetLocale(Locale)
self.Locale = Locale or "ru"
return self
end
--- Configure Dispatcher SRS - Radio voice for radar callouts and assignment instructions.
-- @param #REDGCIDISPATCHER self
-- @param #string Path (Optional) Defaults to "C:\\Program Files\\DCS-SimpleRadio-Standalone\\ExternalAudio"
-- @param #number Frequency Single Frequency, e.g. 124
-- @param #number Modulation Modluation e.g. radio.modulation.AM
-- @param #string Culture (Optional) The cultrue string e.g. "en-EN"
-- @param #string Voice The voice name e.g. MSRS.Voices.Google.Wavenet.de_DE_Wavenet_G
-- @param #number Port (Optional) The SRS Server port, defaults to 5002.
-- @param #number Speed (Optional) Voice speed, defaults to 1.0 (100%)
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetSRS(Path, Frequency, Modulation, Culture, Voice, Port, Speed)
self.SRSPath = Path
self.SRSFreq = Frequency or self.SRSFreq
self.SRSMod = Modulation or self.SRSMod
self.SRSCulture = Culture or self.SRSCulture
self.SRSVoice = Voice or self.SRSVoice
self.SRSPort = Port or self.SRSPort
self.SRSSpeed = Speed or 1
self.SRSFreqPilotsStart = self.SRSFreq + 1
return self
end
--- Enable SRS autotranslation, do not forget to set voices according to language! Requires HOUND as SRS backend!
-- @param #REDGCIDISPATCHER self
-- @param #string languagecode Language to translate to, defaults to "fr". Takes [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) language codes.
-- @param #string provider (optional) Translation provider, defaults to `MSRS.Provider.GOOGLE`
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:EnableSRSAutoTranslate(languagecode,provider)
self.translateEnabled = true
self.translateLanguage = languagecode or "fr"
self.translateProvider = provider or MSRS.Provider.GOOGLE
return self
end
--- Configure available channel numbers and frequencies for pilots.
-- @param #REDGCIDISPATCHER self
-- @param #table RadioTable Table of available channel numbers and their frequencies, indexed by channel number
-- @return #REDGCIDISPATCHER self
-- @usage
-- Use as follows, e.g.
-- local RadioTable = {
-- [1] = 125,
-- [2] = 125.5,
-- [3] = 126,
-- [4] = 126.5,
-- [5] = 127,
-- [6] = 127.5,
-- [7] = 128,
-- [8] = 128.5,
-- [9] = 129,
-- [10] = 129.5,
-- }
-- dispatch:SetRadioChannelList(RadioTable)
function REDGCIDISPATCHER:SetRadioChannelList(RadioTable)
self.RadioChannels = RadioTable
return self
end
--- Configure GCI SRS (the one that guides the pilots during the engagement).
-- @param #REDGCIDISPATCHER self
-- @param #number StartFrequency Single Frequency, e.g. 125 - this is the start frequency for channel 1 of the radio channel list.
-- @param #string Voice The SRS Voice to be used.
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetSRSGCIDetails(StartFrequency,Voice)
self:T({F=StartFrequency,V=Voice})
self.SRSGCIFrequency = StartFrequency or 125
self.SRSGCIVoice = Voice or self.SRSVoice or MSRS.Voices.Google.Wavenet.de_DE_Wavenet_B
return self
end
--- Set SRS provider.
-- @param #REDGCIDISPATCHER self
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetSRSProvider(Provider)
self.SRSProvider = Provider
return self
end
--- Configure the pilot one voice for radio acknowledgements.
-- The pilot uses the same frequency/modulation as the GCI controller but
-- a distinct voice so the two can be told apart on the radio.
-- Set PilotCallsign to nil (default) to disable pilot ACKs entirely.
-- @param #REDGCIDISPATCHER self
-- @param #string PilotCallsign Pilot's callsign (e.g. "Сокол-1"), or nil to disable ACKs.
-- @param #string Culture BCP-47 culture string (default same as GCI)
-- @param #string Voice MSRS voice constant (default ru_RU_Standard_B)
-- @param #number Speaker (Optional) MSRS Speaker for Hound/Piper Voices, e.g. 11 for "318 (11)"
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetPilotOneSRS(PilotCallsign, Culture, Voice, Speaker)
self.PilotOneCallsign = PilotCallsign
self.PilotOneSRSCulture = Culture or self.SRSCulture
self.PilotOneSRSVoice = Voice or MSRS.Voices.Google.Standard.ru_RU_Standard_B
self.PilotOneSRSSpeaker = Speaker
return self
end
--- Configure the pilot two voice for radio acknowledgements.
-- The pilot uses the same frequency/modulation as the GCI controller but
-- a distinct voice so the two can be told apart on the radio.
-- Set PilotCallsign to nil (default) to disable pilot ACKs entirely.
-- @param #REDGCIDISPATCHER self
-- @param #string PilotCallsign Pilot's callsign (e.g. "Сокол-1"), or nil to disable ACKs.
-- @param #string Culture BCP-47 culture string (default same as GCI)
-- @param #string Voice MSRS voice constant (default ru_RU_Standard_B)
-- @param #number Speaker (Optional) MSRS Speaker for Hound/Piper Voices, e.g. 11 for "318 (11)"
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetPilotTwoSRS(PilotCallsign, Culture, Voice, Speaker)
self.PilotTwoCallsign = PilotCallsign
self.PilotTwoSRSCulture = Culture or self.SRSCulture
self.PilotTwoSRSVoice = Voice or MSRS.Voices.Google.Standard.ru_RU_Standard_B
self.PilotTwoSRSSpeaker = Speaker
return self
end
--- Set altitude offset for intercept geometry.
-- @param #REDGCIDISPATCHER self
-- @param #number Meters Default -700 (shoot up)
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetAltOffset(Meters)
self.AltOffset = Meters or -700
return self
end
--- Set weapons-free range.
-- @param #REDGCIDISPATCHER self
-- @param #number Meters Default 20000 (20 km)
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetWFRange(Meters)
self.WFRange = Meters or 20000
return self
end
--- Enable or disable automatic respawn after engagement.
-- New AI will be spawned into the same CAP zone after RespawnDelay seconds.
-- @param #REDGCIDISPATCHER self
-- @param #boolean Enabled Default true
-- @param #number Delay Seconds after RTB before respawn (default 300)
-- @param #number Count Number of AI to spawn (default 2)
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetRespawn(Enabled, Delay, Count)
self.RespawnEnabled = Enabled ~= false
self.RespawnDelay = Delay or 300
self.RespawnCount = Count or 2
return self
end
--- Enable debug logging.
-- @param #REDGCIDISPATCHER self
-- @param #boolean OnOff
-- @return #REDGCIDISPATCHER self
function REDGCIDISPATCHER:SetDebug(OnOff)
self.Debug = OnOff ~= false
return self
end
-- ─────────────────────────────────────────────────────────────
-- Internal helpers
-- ─────────────────────────────────────────────────────────────
--- [INTERNAL]
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:_Log(msg)
if self.Debug then env.info(self.lid .. msg) end
end
--- [INTERNAL] Initialize TEXTANDSOUND localization.
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:_InitLocalization()
self._gettext = TEXTANDSOUND:New("REDGCIDISPATCHER", "en")
for locale, entries in pairs(REDGCIDISPATCHER.Messages) do
local loc = string.lower(tostring(locale))
for id, text in pairs(entries) do
self._gettext:AddEntry(loc, tostring(id), text)
end
end
end
--- [INTERNAL] Fill {PLACEHOLDER} tokens in a template string.
-- @param #REDGCIDISPATCHER self
-- @param #string Template
-- @param #table Vars
-- @return #string
function REDGCIDISPATCHER:_FillTemplate(Template, Vars)
self:T({T=Template,V=Vars})
return (string.gsub(Template, "{([%w_]+)}", function(key)
return tostring(Vars[key] or "")
end))
end
--- [INTERNAL] Dispatch a radio transmission via SRS queue.
-- @param #REDGCIDISPATCHER self
-- @param #string Key Message key from Messages table
-- @param #table Vars Token variables
-- @param #string GroupName For subtitle targeting (optional)
function REDGCIDISPATCHER:_Transmit(Key, Vars, GroupName)
if not self._gettext or not self._msrs or not self._srs_queue then return end
local template = self._gettext:GetEntry(Key, self.Locale)
if not template then
self:_Log("_Transmit: no template for key=" .. tostring(Key))
return
end
local text = self:_FillTemplate(template, Vars or {})
local srstext = string.gsub(text, "%.", ";")
local grp = GroupName and GROUP:FindByName(GroupName) or nil
self._srs_queue:NewTransmission( --text,duration,msrs,tstart,interval,subgroups,subtitle,subduration,frequency,modulation,gender,culture,voice,volume,label,coordinate,speed,speaker,priority)
srstext, nil, self._msrs, 3.0, 2,
grp and { grp } or nil,
text, 8,
nil, nil, nil, nil, nil, nil, "DISPATCH",
nil, nil, nil,75)
self:_Log("[TX/" .. Key .. "] " .. text)
end
--- [INTERNAL] Next callsign number.
-- @param #REDGCIDISPATCHER self
-- @return #number
function REDGCIDISPATCHER:_NextCallsign()
local cs = self._callsign_counter
self._callsign_counter = self._callsign_counter + 1
return cs
end
--- [INTERNAL] Localized tactic name for radio.
-- @param #REDGCIDISPATCHER self
-- @param #string Tactic "PINCER","HIGH_LOW","STAGGER","TRAIL","GIRAFFE"
-- @return #string
function REDGCIDISPATCHER:_TacticToken(Tactic)
local tokens = {
en = { PINCER="pincer", HIGH_LOW="high-low", STAGGER="stagger",
TRAIL="trail", GIRAFFE="giraffe" },
de = { PINCER="Zange", HIGH_LOW="hoch-tief", STAGGER="gestaffelt",
TRAIL="Kette", GIRAFFE="Giraffe" },
ru = { PINCER="клещи", HIGH_LOW="верх-низ", STAGGER="уступ",
TRAIL="цепочка", GIRAFFE="жираф" },
}
local t = tokens[self.Locale] or tokens["en"]
return t[Tactic] or string.lower(Tactic or "?")
end
--- [INTERNAL] Send transmission to all CAP fighters in pool.
-- @param #REDGCIDISPATCHER self
-- @param #string Key Message key
-- @param #table Vars Token variables
function REDGCIDISPATCHER:_TransmitToAllCAP(Key, Vars)
if not self._gettext or not self._msrs or not self._srs_queue then return end
local template = self._gettext:GetEntry(Key, self.Locale)
if not template then
self:_Log("_TransmitToAllCAP: no template for key=" .. tostring(Key))
return
end
local text = self:_FillTemplate(template, Vars or {})
local srstext = string.gsub(text, "%.", ";")
self:T("[TX_ALL/ SRS Text: "..srstext)
-- Collect all CAP groups for subtitle
local subgroups = {}
for _, entry in pairs(self._pool) do
local grp = GROUP:FindByName(entry.groupName)
if grp and grp:IsAlive() then
subgroups[#subgroups + 1] = grp
end
end
self._srs_queue:NewTransmission(
srstext, nil, self._msrs, 2.0, 2,
#subgroups > 0 and subgroups or nil,
text, 8,
nil, nil, nil, nil, nil, nil, "DISPATCH",
nil, nil, nil,100)
self:_Log("[TX_ALL/" .. Key .. "] " .. text)
end
--- [INTERNAL] Get callsign string from group.
-- Reads part after '#' in group name, e.g. "TEMPLATE#101" → "101".
-- Falls back to GetCustomCallSign(), then last part of group name.
-- @param #REDGCIDISPATCHER self
-- @param Wrapper.Group#GROUP Grp
-- @return #string
function REDGCIDISPATCHER:_GetCallsign(Grp)
if not Grp then return "GCI" end
local name = Grp:GetName() or "GCI"
local after_hash = string.match(name, "#(%d+)")
if after_hash then return after_hash end
local cs = Grp:GetCustomCallSign(true, true)
if cs and cs ~= "" then return cs end
return string.match(name, "([^%-]+)$") or name
end
--- [INTERNAL] Check if group has a human pilot.
-- @param #REDGCIDISPATCHER self
-- @param Wrapper.Group#GROUP Grp
-- @return #boolean
function REDGCIDISPATCHER:_IsHuman(Grp)
if not Grp then return false end
if self.ClientSet then
local found = false
self.ClientSet:ForEachClient(function(client)
if client and client:GetGroup() and
client:GetGroup():GetName() == Grp:GetName() then
found = true
end
end)
if found then return true end
end
for _, unit in pairs(Grp:GetUnits() or {}) do
if unit and unit:IsAlive() and unit:GetPlayerName() then return true end
end
return false
end
--- [INTERNAL] Initialize SRS.
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:_InitSRS()
self._msrs = MSRS:New(self.SRSPath, self.SRSFreq, self.SRSMod)
self._msrs:SetPort(self.SRSPort)
self._msrs:SetLabel("DISPATCH")
self._msrs:SetCulture(self.SRSCulture)
self._msrs:SetVoice(self.SRSVoice)
self._msrs:SetCoalition(self.Coalition)
if self.translateEnabled == true then
self._msrs:SetAutoTranslate(self.translateProvider,self.translateLanguage)
end
if self.SRSProvider then self._msrs:SetProvider(self.SRSProvider) end
self._srs_queue = MSRSQUEUE:New("REDGCIDISPATCHER")
end
--- [INTERNAL] Spawn AI group and assign orbit.
-- Uses SPAWN:NewWithAlias() so group name contains callsign.
-- Template controls parking, hot/cold, heading.
-- OnSpawnGroup callback ensures group is alive before orbit assignment.
-- @param #REDGCIDISPATCHER self
-- @param Core.Zone#ZONE Zone
-- @return Wrapper.Group#GROUP or nil
function REDGCIDISPATCHER:_SpawnAI(Zone)
local cs = self:_NextCallsign()
local alias = self.TemplateName .. "#" .. cs
local spawner = SPAWN:NewWithAlias(self.TemplateName, alias)
spawner:InitCallSignRed(cs)
spawner:InitModex(cs)
spawner:OnSpawnGroup(function(grp)
self:T(self.lid .. "Gespawnt: " .. grp:GetName() ..
" CS=" .. cs .. " -> orbit in " .. Zone:GetName())
self:_RegisterFighter(grp, Zone, false)
self:_AssignOrbit(grp, Zone)
end)
local grp = spawner:Spawn()
return grp
end
--- [INTERNAL] Toggle radar emission on the fighter group.
-- @param #REDGCIDISPATCHER self
-- @param #boolean On
-- @param #number Delay
function REDGCIDISPATCHER:_SetRadar(Grp,On,Delay)
--if not self.IsAIPlane then return end
if Delay then
self:ScheduleOnce(Delay,REDGCIDISPATCHER._SetRadar,self,Grp,On)
return
end
local grp = Grp
if not grp then return end
if On == true then
grp:SetOptionRadarUsingForContinousSearch()
grp:OptionECM_DetectedLockByRadar()
grp:SetOptionJettisonEmptyTanks(true)
else
grp:SetOptionRadarUsingNever()
grp:OptionECM_Never()
end
self:_Log("Radar " .. (On and "ON" or "OFF"))
end
--- [INTERNAL] Toggle weapons free on the fighter group.
-- @param #REDGCIDISPATCHER self
-- @param #boolean On
-- @param #number Delay Delay in seconds
function REDGCIDISPATCHER:_SetWeaponsFree(Grp,On,Delay)
--if not self.IsAIPlane then return end
if Delay then
self:ScheduleOnce(Delay,REDGCIDISPATCHER._SetWeaponsFree,self,Grp,On)
return
end
local grp = Grp
if not grp then return end
if On == true then
grp:OptionROEWeaponFree()
grp:OptionAlarmStateRed()
grp:OptionAAAttackRange(self._missilerangeflag)
grp:OptionECM_DetectedLockByRadar()
grp:SetOptionJettisonEmptyTanks(true)
else
grp:OptionROEHoldFire()
grp:OptionAlarmStateAuto()
grp:OptionAAAttackRange(3)
grp:OptionECM_Never()
end
self:_Log("Weapons " .. (On and "ON" or "OFF"))
end
--- [INTERNAL] Assign orbit task in CAP zone.
-- WaypointAir expects speed in kph or km/h; TaskOrbit expects mps or m/s.
-- Altitude variation prevents all groups flying at exact same level.
-- @param #REDGCIDISPATCHER self
-- @param Wrapper.Group#GROUP Grp
-- @param Core.Zone#ZONE Zone
function REDGCIDISPATCHER:_AssignOrbit(Grp, Zone)
if not Grp or not Grp:IsAlive() then return end
if Grp:GetPlayerName() == nil then
-- Ensure radar and weapons are cold in CAP
self:_SetRadar(Grp, false, 1)
self:_SetWeaponsFree(Grp, false, 1)
end
local center = Zone:GetCoordinate()
local variation = UTILS.Round((UTILS.LCGRandom() * 1000),-2)
local alt = self.OrbitAlt + variation
local spd_mps = UTILS.KmphToMps(self.OrbitSpeed)
local spd_tas = UTILS.IasToTas(self.OrbitSpeed, alt)
self:T(self.lid .. "Orbit variation: " .. variation)
-- TaskOrbit requires speed in m/s
local task = Grp:TaskOrbit(center, alt, spd_mps)
-- WaypointAir requires speed in km/h
local wp0 = Grp:GetCoordinate():WaypointAir(
COORDINATE.WaypointAltType.BARO,
COORDINATE.WaypointType.TurningPoint,
COORDINATE.WaypointAction.FlyoverPoint,
self.OrbitSpeed, false, nil, {}, "TRANSIT")
local wp2c = COORDINATE:New(center.x, alt, center.z)
-- Intermediate waypoint for clean climb profile (km/h)
local wp1 = Grp:GetCoordinate():GetIntermediateCoordinate(wp2c, 0.5)
:SetAltitude(1000)
:WaypointAir(
COORDINATE.WaypointAltType.BARO,
COORDINATE.WaypointType.TurningPoint,
COORDINATE.WaypointAction.FlyoverPoint,
spd_tas, true, nil, {}, "TRANSIT")
-- Orbit waypoint (km/h TAS)
local wp2 = wp2c:WaypointAir(
COORDINATE.WaypointAltType.BARO,
COORDINATE.WaypointType.TurningPoint,
COORDINATE.WaypointAction.FlyoverPoint,
spd_tas, true, nil, { task }, "CAP_ORBIT")
Grp:Route({ wp0, wp1, wp2 }, 2)
self:_Log(Grp:GetName() .. " -> orbit " .. Zone:GetName() ..
string.format(" %.0fm %.0fkph TAS", alt, spd_tas))
end
--- [INTERNAL] Register fighter in pool.
-- @param #REDGCIDISPATCHER self
-- @param Wrapper.Group#GROUP Grp
-- @param Core.Zone#ZONE Zone
-- @param #boolean IsHuman
function REDGCIDISPATCHER:_RegisterFighter(Grp, Zone, IsHuman)
local name = Grp:GetName()
self._pool[name] = {
groupName = name,
group = Grp,
zone = Zone,
isHuman = IsHuman or false,
state = REDGCIDISPATCHER.STATE_CAP,
pairedWith = nil,
engagement = nil,
callsign = self:_GetCallsign(Grp),
}
self:T(self.lid .. "Pool+: " .. name ..
" CS=" .. self._pool[name].callsign ..
(IsHuman and " [HUMAN]" or " [AI]"))
end
--- [INTERNAL] Refresh human pool from ClientSet.
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:_RefreshHumanPool()
if not self.ClientSet then return end
self.ClientSet:ForEachClient(function(client)
if not client then return end
local grp = client:GetGroup()
if not grp or not grp:IsAlive() then return end
local name = grp:GetName()
if self._pool[name] then
self._pool[name].isHuman = true
if self._pool[name].state ~= REDGCIDISPATCHER.STATE_ENGAGED and
self._pool[name].state ~= REDGCIDISPATCHER.STATE_RTB then
local in_zone = false
if self.ZoneSet then
self.ZoneSet:ForEachZone(function(zone)
if grp:IsPartlyOrCompletelyInZone(zone) then
in_zone = true
self._pool[name].zone = zone
end
end)
end
self._pool[name].state = in_zone and
REDGCIDISPATCHER.STATE_CAP or REDGCIDISPATCHER.STATE_UNKNOWN
end
return
end
-- New human — find closest CAP zone within 50km
local closest_zone, closest_dist = nil, math.huge
if self.ZoneSet then
self.ZoneSet:ForEachZone(function(zone)
local d = grp:GetCoordinate():Get2DDistance(zone:GetCoordinate())
if d < closest_dist then
closest_dist = d
closest_zone = zone
end
end)
end
if closest_zone and closest_dist < 50000 then
self:_RegisterFighter(grp, closest_zone, true)
end
end)
end
--- [INTERNAL] Return available fighters sorted by priority.
-- Humans first, then by distance to cluster centroid.
-- Safe iteration — collects dead groups separately before removing.
-- @param #REDGCIDISPATCHER self
-- @param #table Centroid DCS coordinates {x, z}
-- @return #table
function REDGCIDISPATCHER:_AvailableFighters(Centroid)
local available = {}
local to_remove = {}
for _, entry in pairs(self._pool) do
if entry.state == REDGCIDISPATCHER.STATE_CAP then
local grp = GROUP:FindByName(entry.groupName)
if grp and grp:IsAlive() then
if Centroid then
local c = grp:GetCoordinate()
entry._dist = c and c:Get2DDistance(Centroid) or math.huge
else
entry._dist = math.huge
end
available[#available + 1] = entry
else
to_remove[#to_remove + 1] = entry.groupName
end
end
end
-- Safe removal after iteration
for _, name in ipairs(to_remove) do
self:_Log("Pool-: " .. name .. " dead (AvailableFighters)")
self._pool[name] = nil
end
table.sort(available, function(a, b)
if a.isHuman ~= b.isHuman then return a.isHuman end
return (a._dist or math.huge) < (b._dist or math.huge)
end)
return available
end
--- [INTERNAL] Stable cluster key.
-- @param #REDGCIDISPATCHER self
-- @param #table Cluster
-- @return #string
function REDGCIDISPATCHER:_ClusterKey(Cluster)
local c = Cluster.coordinate or {}
local cz = c.z or c.y or 0
return string.format("C_%.0f_%.0f", (c.x or 0)/1000, cz/1000)
end
--- [INTERNAL] Count alive units in cluster.
-- @param #REDGCIDISPATCHER self
-- @param #table Cluster
-- @return #number
function REDGCIDISPATCHER:_ClusterSize(Cluster)
local n = 0
for _, contact in pairs(Cluster.Contacts or {}) do
local grp = GROUP:FindByName(contact.groupname)
if grp and grp:IsAlive() then n = n + grp:CountAliveUnits() end
end
return n
end
--- [INTERNAL] Derive localized count and type tokens for a cluster.
-- Reuses REDGCI CountTokens/TypeTokens tables.
-- @param #REDGCIDISPATCHER self
-- @param #table Cluster
-- @return #string count_str, #string type_str, #number rng_km
function REDGCIDISPATCHER:_ClusterPicture(Cluster)
local size = self:_ClusterSize(Cluster)
-- Count token (reuse REDGCI table if available)
local count_str
if REDGCI and REDGCI.CountTokens then
local t = REDGCI.CountTokens[self.Locale] or REDGCI.CountTokens["en"]
if size == 1 then count_str = t.single
elseif size == 2 then count_str = t.pair
elseif size <= 4 then count_str = t.group
else count_str = t.biggroup
end
else
count_str = tostring(size)
end
-- Type token from RCS (reuse REDGCI table if available)
local type_str = "fighter"
local rcs_sum, rcs_n = 0.0, 0
for _, contact in pairs(Cluster.Contacts or {}) do
if contact.rcs then
rcs_sum = rcs_sum + contact.rcs
rcs_n = rcs_n + 1
end
end
if REDGCI and REDGCI.TypeTokens and rcs_n > 0 then
local t = REDGCI.TypeTokens[self.Locale] or REDGCI.TypeTokens["en"]
local avg = rcs_sum / rcs_n
if avg < (REDGCI.RCS_FIGHTER_MAX or 6.0) then type_str = t.fighter
elseif avg > (REDGCI.RCS_BOMBER_MIN or 20.0) then type_str = t.bomber
else type_str = t.machines
end
elseif REDGCI and REDGCI.TypeTokens then
local t = REDGCI.TypeTokens[self.Locale] or REDGCI.TypeTokens["en"]
type_str = t[type_str]
end
-- Range to centroid from nearest CAP fighter
local rng_km = 0
local centroid = Cluster.coordinate
if centroid then
local min_dist = math.huge
for _, entry in pairs(self._pool) do
local grp = GROUP:FindByName(entry.groupName)
if grp and grp:IsAlive() then
local c = grp:GetCoordinate()
if c then
local d = c:Get2DDistance(centroid)
if d < min_dist then min_dist = d end
end
end
end
if min_dist < math.huge then
rng_km = math.floor(min_dist / 1000 + 0.5)
end
end
return count_str, type_str, rng_km
end
--- [INTERNAL] Get up to 2 target group names from cluster (closest to centroid).
-- @param #REDGCIDISPATCHER self
-- @param #table Cluster
-- @return #string T1name, #string T2name
function REDGCIDISPATCHER:_ClusterTargets(Cluster)
local c = Cluster.coordinate or { x=0, z=0, y=0 }
local cz = c.z or c.y or 0
local groups = {}
for _, contact in pairs(Cluster.Contacts or {}) do
local grp = GROUP:FindByName(contact.groupname)
if grp and grp:IsAlive() then
local coord = grp:GetCoordinate()
local dx = (coord and coord.x or 0) - c.x
local dz = (coord and coord.z or 0) - cz
groups[#groups + 1] = { name=contact.groupname, dist=math.sqrt(dx*dx+dz*dz) }
end
end
table.sort(groups, function(a, b) return a.dist < b.dist end)
return groups[1] and groups[1].name or nil,
groups[2] and groups[2].name or nil
end
--- [INTERNAL] Dispatch one pair against a cluster.
-- @param #REDGCIDISPATCHER self
-- @param #table F1entry
-- @param #table F2entry (may be nil for solo)
-- @param #table Cluster
-- @param #string ClusterKey
function REDGCIDISPATCHER:_DispatchPair(F1entry, F2entry, Cluster, ClusterKey)
local t1, t2 = self:_ClusterTargets(Cluster)
if not t1 then
self:_Log("Dispatch: keine Ziele in " .. ClusterKey)
return
end
if not self.dispatchcount then self.dispatchcount = 0 end
local f1 = F1entry.groupName
local f2 = F2entry and F2entry.groupName or nil
local cs1 = F1entry.callsign
local cs2 = F2entry and F2entry.callsign or cs1
self:T(self.lid .. string.format(
"DISPATCH %s(%s)+%s(%s) -> %s/%s [%s]",
f1, cs1, f2 or "-", cs2, t1, t2 or "-", ClusterKey))
F1entry.state = REDGCIDISPATCHER.STATE_ENGAGED
F1entry.pairedWith = f2
F1entry.engagement = ClusterKey
if F2entry then
F2entry.state = REDGCIDISPATCHER.STATE_ENGAGED
F2entry.pairedWith = f1
F2entry.engagement = ClusterKey
end
local gci2v2 = REDGCI2v2:New(-- Functional.RedGCI2v2#REDGCI2v2
f1, f2 or f1, t1, t2 or t1, cs1, cs2, self.Coalition)
gci2v2:SetLocale(self.Locale)
gci2v2:SetAIMode(true, self.HomeBaseName)
gci2v2:SetSRS(self.SRSPath, self.SRSGCIFrequency+self.dispatchcount, self.SRSMod,
self.SRSCulture, self.SRSGCIVoice, self.SRSPort, self.SRSSpeed)
if self.SRSProvider then gci2v2:SetSRSProvider(self.SRSProvider) end
gci2v2:SetAltOffset(self.AltOffset)
gci2v2:SetWFRange(self.WFRange)
gci2v2:SetDebug(self.Debug)
gci2v2.Coalition = self.Coalition
gci2v2:SetRadioChannelList(self.RadioChannels)
-- Stop-Hook: release fighters back to pool
local dr = self
local orig = gci2v2.onafterStop
gci2v2.onafterStop = function(s, F, E, T)
if orig then orig(s, F, E, T) end
dr:_OnEngagementEnd(ClusterKey, F1entry, F2entry)
end
gci2v2:Start()
-- Dispatch-Meldung: Picture an Piloten.
-- Tactic is not known yet at dispatch time (REDGCI2v2 picks it at COMMIT).
-- We send Count/Type now; the tactic call comes from the GCI itself.
local count_str, type_str, _ = self:_ClusterPicture(Cluster)
local cs_text = cs1 .. (f2 and (", " .. cs2) or "")
self:_Transmit("DISPATCH_CALL", {
CALLSIGN = cs_text,
COUNT = count_str,
TYPE = type_str,
}, f1)
-- Picture call: cluster size
local sz = self:_ClusterSize(Cluster)
if gci2v2._gci1 then gci2v2._gci1._target_count = sz end
if gci2v2._gci2 then gci2v2._gci2._target_count = sz end
self._engagements[ClusterKey] = {
gci2v2 = gci2v2,
f1Name = f1,
f2Name = f2,
clusterKey = ClusterKey,
startTime = timer.getTime(),
}
self.dispatchcount = (self.dispatchcount+1)%11
return self
end
--- [INTERNAL] Called when engagement ends (REDGCI2v2 Stop fires).
-- AI → RTB waypoint then removed from pool.
-- Human → RTB radio call, re-enters pool after 5 min.
-- @param #REDGCIDISPATCHER self
-- @param #string ClusterKey
-- @param #table F1entry
-- @param #table F2entry
function REDGCIDISPATCHER:_OnEngagementEnd(ClusterKey, F1entry, F2entry)
self:T(self.lid .. "Engagement end: " .. ClusterKey)
self._engagements[ClusterKey] = nil
local entries = F2entry and { F1entry, F2entry } or { F1entry }
for _, entry in ipairs(entries) do
entry.pairedWith = nil
entry.engagement = nil
if entry.isHuman then
entry.state = REDGCIDISPATCHER.STATE_RTB
self:_Transmit("RTB_CALL",
{ CALLSIGN = entry.callsign or entry.groupName },
entry.groupName)
local name = entry.groupName
self:ScheduleOnce(300, function()
if self._pool[name] then
self._pool[name].state = REDGCIDISPATCHER.STATE_UNKNOWN
self:T(self.lid .. name .. " [HUMAN] released")
end
end)
else
entry.state = REDGCIDISPATCHER.STATE_RTB
self:_RTBAircraft(entry.groupName)
local name = entry.groupName
self:ScheduleOnce(600, function()
self._pool[name] = nil
self:T(self.lid .. name .. " [AI] removed from pool")
end)
-- Respawn: spawn new AI into same zone after delay
if self.RespawnEnabled and entry.zone then
local zone = entry.zone
self:ScheduleOnce(self.RespawnDelay, function()
self:T(self.lid .. "Respawn: " .. self.RespawnCount ..
"× AI → " .. zone:GetName())
for i = 1, self.RespawnCount do
self:_SpawnAI(zone)
end
end)
end
end
end
end
--- [INTERNAL] Push RTB waypoint for AI group.
-- WaypointAir expects speed in km/h.
-- @param #REDGCIDISPATCHER self
-- @param #string GroupName
function REDGCIDISPATCHER:_RTBAircraft(GroupName)
if not self.HomeBase then return end
local grp = GROUP:FindByName(GroupName)
if not grp or not grp:IsAlive() then return end
local spd_kmh = math.max(self.OrbitSpeed, 400) -- km/h for WaypointAir
local c0 = grp:GetCoordinate()
local c1 = self.HomeBase:GetCoordinate()
grp:Route({
c0:WaypointAir(COORDINATE.WaypointAltType.BARO,
COORDINATE.WaypointType.TurningPoint,
COORDINATE.WaypointAction.FlyoverPoint,
spd_kmh, true, nil, {}, "RTB"),
c1:WaypointAir(COORDINATE.WaypointAltType.BARO,
COORDINATE.WaypointType.Land,
COORDINATE.WaypointAction.Landing,
spd_kmh, true, self.HomeBase, {}, "LAND"),
}, 3)
self:T(self.lid .. GroupName .. " [AI] RTB -> " .. self.HomeBaseName)
end
-- ─────────────────────────────────────────────────────────────
-- FSM handlers
-- ─────────────────────────────────────────────────────────────
--- [INTERNAL] Start handler.
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:onafterStart(From, Event, To)
self:T(self.lid .. "Start v" .. REDGCIDISPATCHER.version)
if not self.Intel then self:E(self.lid .. "ERROR: no INTEL set!") return end
if not self.ZoneSet then self:E(self.lid .. "ERROR: no ZoneSet!") return end
if not self.HomeBase then self:E(self.lid .. "ERROR: no HomeBase set!") return end
self:_InitSRS()
self:_InitLocalization()
self.ZoneSet:ForEachZone(function(zone)
self:T(self.lid .. "Zone: " .. zone:GetName() ..
" -> spawning " .. self.AiPerZone .. "x AI")
for i = 1, self.AiPerZone do
self:_SpawnAI(zone)
end
end)
self:__Status(-REDGCIDISPATCHER.TICK_INTERVAL)
end
--- [INTERNAL] Main dispatch tick.
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:onafterStatus(From, Event, To)
self:_RefreshHumanPool()
-- Safe removal of dead AI: collect first, then delete
local to_remove = {}
for name, entry in pairs(self._pool) do
if not entry.isHuman then
local grp = GROUP:FindByName(name)
if (not grp or not grp:IsAlive()) and
entry.state ~= REDGCIDISPATCHER.STATE_RTB then
to_remove[#to_remove + 1] = name
end
end
end
for _, name in ipairs(to_remove) do
self:_Log("Pool-: " .. name .. " dead")
self._pool[name] = nil
end
local clusters = self.Intel:GetClusterTable()
if not clusters then
self:__Status(-REDGCIDISPATCHER.TICK_INTERVAL)
return
end
-- Clean up known clusters that have disappeared
for key in pairs(self._known_clusters) do
local still_alive = false
for _, cluster in pairs(clusters) do
if self:_ClusterKey(cluster) == key and
self:_ClusterSize(cluster) > 0 then
still_alive = true
break
end
end
if not still_alive then
self._known_clusters[key] = nil
self:_Log("Cluster " .. key .. " gone - removed from known_clusters")
end
end
for _, cluster in pairs(clusters) do
local key = self:_ClusterKey(cluster)
local size = self:_ClusterSize(cluster)
if size > 0 then
-- New cluster? -> send INTEL contact to all CAP fighters
if not self._known_clusters[key] then
self._known_clusters[key] = true
local count_str, type_str, rng_km = self:_ClusterPicture(cluster)
self:_TransmitToAllCAP("INTEL_CONTACT", {
COUNT = count_str,
TYPE = type_str,
RNG = rng_km,
})
self:T(self.lid .. "Neuer Cluster " .. key ..
" → INTEL_CONTACT gesendet")
end
local active = 0
for ek in pairs(self._engagements) do
if string.find(ek, key, 1, true) then active = active + 1 end
end
local needed = math.ceil(size / REDGCIDISPATCHER.UNITS_PER_PAIR)
local to_send = math.max(0, needed - active)
if to_send > 0 then
local centroid = cluster.coordinate
local available = self:_AvailableFighters(centroid)
self:T(self.lid .. string.format(
"Cluster %s size=%d need=%d pairs avail=%d fighter",
key, size, needed, #available))
if #available > 0 then
local idx, dispatched = 1, 0
while dispatched < to_send and idx + 1 <= #available do
self:_DispatchPair(
available[idx], available[idx+1],
cluster, key .. "_P" .. (active + dispatched + 1))
idx = idx + 2
dispatched = dispatched + 1
end
if dispatched == 0 and #available >= 1 and size <= 1 then
self:_DispatchPair(available[1], nil, cluster,
key .. "_P" .. (active + 1))
end
end
end
end
end
self:__Status(-REDGCIDISPATCHER.TICK_INTERVAL)
end
--- [INTERNAL] Stop handler.
-- @param #REDGCIDISPATCHER self
function REDGCIDISPATCHER:onafterStop(From, Event, To)
self:T(self.lid .. "Stopped.")
end
-------------------------------------------------------------------------------
-- END of Class
-------------------------------------------------------------------------------