mirror of
https://github.com/FlightControl-Master/MOOSE.git
synced 2026-07-22 19:44:05 +00:00
1347 lines
54 KiB
Lua
1347 lines
54 KiB
Lua
--- **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 10–15 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 8–13km 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 |
|
||
-- | | (300–600m 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
|
||
------------------------------------------------------------------------------- |