This commit is contained in:
Applevangelist
2026-03-27 15:10:23 +01:00
parent 0806d822ff
commit 84e20dae29
8 changed files with 4697 additions and 11 deletions
+26 -9
View File
@@ -1241,6 +1241,16 @@ function SPAWN:InitCallSign(ID,Name,Minor,Major)
return self
end
--- [RED AIR only!] This method sets a specific callsign for a spawned group.
-- @param #SPAWN self
-- @param #number ID The number with which to start for the first unit, e.g. 100, further units would then be 101, 102 .. etc.
-- @return #SPAWN self
function SPAWN:InitCallSignRed(ID)
self.SpawnInitCallSign = true
self.SpawnInitCallSignID = ID or 100
self.SpawnInitCallSignRED = true
end
--- This method sets a spawn position for the group that is different from the location of the template.
-- @param #SPAWN self
-- @param Core.Point#COORDINATE Coordinate The position to spawn from
@@ -3550,14 +3560,21 @@ function SPAWN:_Prepare( SpawnTemplatePrefix, SpawnIndex ) -- R2.2
end
if self.SpawnInitCallSign then
for UnitID = 1, #SpawnTemplate.units do
local Callsign = SpawnTemplate.units[UnitID].callsign
if Callsign and type( Callsign ) ~= "number" then
SpawnTemplate.units[UnitID].callsign[1] = self.SpawnInitCallSignID
SpawnTemplate.units[UnitID].callsign[2] = self.SpawnInitCallSignMinor
SpawnTemplate.units[UnitID].callsign[3] = self.SpawnInitCallSignMajor
SpawnTemplate.units[UnitID].callsign["name"] = string.format("%s%d%d",self.SpawnInitCallSignName,self.SpawnInitCallSignMinor,self.SpawnInitCallSignMajor)
--UTILS.PrintTableToLog(SpawnTemplate.units[UnitID].callsign,1)
if self.SpawnInitCallSignRED == true then
for UnitID = 1, #SpawnTemplate.units do
SpawnTemplate.units[UnitID].callsign = self.SpawnInitCallSignID
self.SpawnInitCallSignID = self.SpawnInitCallSignID + 1
end
else
for UnitID = 1, #SpawnTemplate.units do
local Callsign = SpawnTemplate.units[UnitID].callsign
if Callsign and type( Callsign ) ~= "number" then
SpawnTemplate.units[UnitID].callsign[1] = self.SpawnInitCallSignID
SpawnTemplate.units[UnitID].callsign[2] = self.SpawnInitCallSignMinor
SpawnTemplate.units[UnitID].callsign[3] = self.SpawnInitCallSignMajor
SpawnTemplate.units[UnitID].callsign["name"] = string.format("%s%d%d",self.SpawnInitCallSignName,self.SpawnInitCallSignMinor,self.SpawnInitCallSignMajor)
--UTILS.PrintTableToLog(SpawnTemplate.units[UnitID].callsign,1)
end
end
end
end
@@ -3573,7 +3590,7 @@ function SPAWN:_Prepare( SpawnTemplatePrefix, SpawnIndex ) -- R2.2
local CallsignLen = CallsignName:len()
SpawnTemplate.units[UnitID].callsign[2] = UnitID
SpawnTemplate.units[UnitID].callsign["name"] = CallsignName:sub( 1, CallsignLen ) .. SpawnTemplate.units[UnitID].callsign[2] .. SpawnTemplate.units[UnitID].callsign[3]
elseif type( Callsign ) == "number" then
elseif type( Callsign ) == "number" and self.SpawnInitCallSignRED ~= true then
SpawnTemplate.units[UnitID].callsign = Callsign + SpawnIndex
end
end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,760 @@
--- **Functional** - Enhanced Warsaw Pact GCI Controller.
--
-- ## Main Features:
--
-- * Guide AI and human pilots in Warsaw Pact Style. GCI Kernel Functions.
-- * Advanced Tactics for Groups.
-- * Many additional events that the mission designer can hook into.
--
-- ===
--
-- ### Author: **Applevangelist**
--
-- ===
-- @module Functional.REDGCI_KERNEL
-- @image Func_RedGCI.png
-- @version 1.0.0
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--- REDGCI_KERNEL class
-- @type REDGCI_KERNEL
---
-- # 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 (REDGCI_DISPATCHER)
--
-- 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 REDGCI_KERNEL
REDGCI_KERNEL = {}
REDGCI_KERNEL.version = "1.0.0"
-- ─────────────────────────────────────────────────────────────
-- Konstanten (aus gci_types.h)
-- ─────────────────────────────────────────────────────────────
REDGCI_KERNEL.C = {
RANGE_VECTOR_START = 60000.0,
RANGE_COMMIT = 30000.0,
RANGE_RADAR_FLOOR = 20000.0,
RANGE_VISUAL = 5000.0,
RANGE_MERGE = 2000.0,
WF_RANGE_MAX = 25000.0,
ALT_OFFSET_LOOKDOWN = 0.0,
ASPECT_NOTCH_MIN = 80.0,
ASPECT_NOTCH_MAX = 100.0,
ASPECT_REAR_ATTACK = 120.0,
MAX_TTI = 600.0,
TICK_INTERVAL = 15.0,
FUEL_BINGO = 0.25,
DELAY_MIN = 3.0,
DELAY_MAX = 8.0,
DELAY_MERGE_MIN = 2.0,
DELAY_MERGE_MAX = 4.0,
TACTIC_PINCER = 0,
TACTIC_HIGH_LOW = 1,
TACTIC_STAGGER = 2,
TACTIC_TRAIL = 3,
TACTIC_GIRAFFE = 4, -- Irak/Iran-Doktrin: F1=Decoy normal, F2=Nap-of-Earth im Groundclutter
PURSUIT_COLLISION = 0,
PURSUIT_LEAD = 1,
PURSUIT_PURE = 2,
PURSUIT_NO_SOLUTION = 3,
}
-- ─────────────────────────────────────────────────────────────
-- Hilfsfunktionen
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
function REDGCI_KERNEL.vec2len(x, z)
return math.sqrt(x*x + z*z)
end
function REDGCI_KERNEL.bearing(dx, dz)
local b = math.deg(math.atan2(dx, dz))
return b < 0.0 and b + 360.0 or b
end
function REDGCI_KERNEL.randDelay(lo, hi)
return lo + UTILS.LCGRandom() * (hi - lo)
end
-- ─────────────────────────────────────────────────────────────
-- Aspect Angle (0=Nose-on, 90=Beam, 180=Tail)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.aspectAngle(target, observer)
local dx = observer.x - target.x
local dz = observer.z - target.z
local range = REDGCI_KERNEL.vec2len(dx, dz)
if range < 1.0 then return 0.0 end
local nx = dx / range
local nz = dz / range
local spd = (target.speed or 1.0) + 1e-6
local tvx = (target.vx or 0.0) / spd
local tvz = (target.vz or 0.0) / spd
local dot = REDGCI_KERNEL.clamp(tvx*nx + tvz*nz, -1.0, 1.0)
return math.deg(math.acos(dot))
end
-- ─────────────────────────────────────────────────────────────
-- Closure Rate (positiv = Annäherung)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.closureRate(f, t)
local dx = t.x - f.x
local dz = t.z - f.z
local range = REDGCI_KERNEL.vec2len(dx, dz)
if range < 1.0 then return 0.0 end
local nx = dx / range
local nz = dz / range
local dvx = (t.vx or 0.0) - (f.vx or 0.0)
local dvz = (t.vz or 0.0) - (f.vz or 0.0)
return -(dvx*nx + dvz*nz)
end
-- ─────────────────────────────────────────────────────────────
-- _solveCollision (intern, GCI-Koordinaten)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL._solveCollision(f, t)
local C = REDGCI_KERNEL.C
local dx = t.x - f.x
local dz = t.z - f.z
local vtx = t.vx or 0.0
local vtz = t.vz or 0.0
local vf = f.speed or 1.0
local a = vtx*vtx + vtz*vtz - vf*vf
local b = 2.0 * (dx*vtx + dz*vtz)
local c = dx*dx + dz*dz
local sol_t = -1.0
if math.abs(a) < 1.0 then
if math.abs(b) > 0.01 then
sol_t = -c / b
else
return false
end
else
local disc = b*b - 4.0*a*c
if disc < 0.0 then return false end
local sq = math.sqrt(disc)
local t1 = (-b - sq) / (2.0 * a)
local t2 = (-b + sq) / (2.0 * a)
if t1 > 0.0 and t2 > 0.0 then sol_t = math.min(t1, t2)
elseif t1 > 0.0 then sol_t = t1
elseif t2 > 0.0 then sol_t = t2
else return false
end
end
if sol_t < 0.0 or sol_t > C.MAX_TTI then return false end
local ip = {
x = t.x + vtx * sol_t,
z = t.z + vtz * sol_t,
y = math.max(t.y + C.ALT_OFFSET_LOOKDOWN, 300.0),
}
local hdg = REDGCI_KERNEL.bearing(ip.x - f.x, ip.z - f.z)
return true, hdg, sol_t, ip
end
-- ─────────────────────────────────────────────────────────────
-- _solveLead (intern, GCI-Koordinaten)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL._solveLead(f, t)
local C = REDGCI_KERNEL.C
local dx = t.x - f.x
local dz = t.z - f.z
local range = REDGCI_KERNEL.vec2len(dx, dz)
if range < 1.0 then return 0.0, 0.0 end
local base_bearing = REDGCI_KERNEL.bearing(dx, dz)
local aspect_rad = math.rad(REDGCI_KERNEL.aspectAngle(t, f))
local speed_ratio = (t.speed or 1.0) / ((f.speed or 1.0) + 1e-6)
local sin_lead = REDGCI_KERNEL.clamp(speed_ratio * math.sin(aspect_rad), -1.0, 1.0)
local lead_deg = math.deg(math.asin(sin_lead))
local hdg
if math.abs(lead_deg) < 45.0 then
hdg = (base_bearing + lead_deg + 360.0) % 360.0
else
hdg = base_bearing
end
local closing = REDGCI_KERNEL.closureRate(f, t)
if closing < 50.0 then closing = 50.0 end
local tti = math.min(range / closing, C.MAX_TTI)
return hdg, tti
end
-- ─────────────────────────────────────────────────────────────
-- computeIntercept (GCI-Koordinaten intern)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.computeIntercept(fighter, target)
local C = REDGCI_KERNEL.C
local dx = target.x - fighter.x
local dz = target.z - fighter.z
local sol = {
heading_deg = 0.0,
time_to_intercept = C.MAX_TTI,
intercept_point = { x=target.x, z=target.z, y=target.y },
target_alt = target.y,
solution_found = false,
aspect_angle = REDGCI_KERNEL.aspectAngle(target, fighter),
range = REDGCI_KERNEL.vec2len(dx, dz),
mode = C.PURSUIT_NO_SOLUTION,
weapons_free = false,
}
local closure = REDGCI_KERNEL.closureRate(fighter, target)
local projected_range = sol.range - closure * C.TICK_INTERVAL
sol.weapons_free = (sol.range < C.WF_RANGE_MAX) or
(closure > 0.0 and projected_range < C.WF_RANGE_MAX)
local ok, hdg, tti, ip = REDGCI_KERNEL._solveCollision(fighter, target)
if ok then
sol.heading_deg = hdg
sol.time_to_intercept = tti
sol.intercept_point = ip
sol.solution_found = true
sol.mode = C.PURSUIT_COLLISION
return sol
end
local lhdg, ltti = REDGCI_KERNEL._solveLead(fighter, target)
sol.heading_deg = lhdg
sol.time_to_intercept = ltti
sol.solution_found = true
sol.mode = C.PURSUIT_LEAD
sol.intercept_point = {
x = target.x + (target.vx or 0.0) * ltti,
z = target.z + (target.vz or 0.0) * ltti,
y = math.max(target.y + C.ALT_OFFSET_LOOKDOWN, 300.0),
}
return sol
end
-- ─────────────────────────────────────────────────────────────
-- computeInterceptDCS (DCS-Koordinaten Ein/Aus)
-- Flat-Rückgabe kompatibel mit alter C-API
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.computeInterceptDCS(f, t)
local f_gci = { x=f.z, z=f.x, y=f.y, vx=f.vz, vz=f.vx, vy=f.vy, speed=f.spd }
local t_gci = { x=t.z, z=t.x, y=t.y, vx=t.vz, vz=t.vx, vy=t.vy, speed=t.spd }
local sol = REDGCI_KERNEL.computeIntercept(f_gci, t_gci)
-- GCI→DCS: ip.z(Nord)→DCS.x, ip.x(Ost)→DCS.z
return sol.heading_deg,
sol.time_to_intercept,
sol.mode,
sol.weapons_free,
sol.range,
sol.aspect_angle,
sol.intercept_point.z,
sol.intercept_point.x,
sol.intercept_point.y,
sol.target_alt
end
-- ─────────────────────────────────────────────────────────────
-- computeSplit (GCI-Koordinaten intern)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.computeSplit(f1, f2, tgt, tactic, variation)
local C = REDGCI_KERNEL.C
variation = REDGCI_KERNEL.clamp(variation or 0.5, 0.0, 1.0)
tactic = tactic or C.TACTIC_PINCER
local mid_x = (f1.x + f2.x) * 0.5
local mid_z = (f1.z + f2.z) * 0.5
local dx = tgt.x - mid_x
local dz = tgt.z - mid_z
local rng = REDGCI_KERNEL.vec2len(dx, dz)
if rng < 1.0 then rng = 1.0 end
local ax = dx / rng
local az = dz / rng
local px = -az
local pz = ax
local plan = {
tactic = tactic,
wp_f1 = { x=0, z=0, y=0 },
wp_f2 = { x=0, z=0, y=0 },
merge_f1 = { x=0, z=0, y=0 },
merge_f2 = { x=0, z=0, y=0 },
}
local function clamp_alt(pt)
if pt.y < 300.0 then pt.y = 300.0 end
end
local function clamp_alt_noe(pt)
if pt.y < 150.0 then pt.y = 150.0 end
end
-- GIRAFFE F2 fliegt NOE — separater clamp mit niedrigerer Mindesthöhe
local function clamp_alt_noe(pt)
if pt.y < 150.0 then pt.y = 150.0 end
end
if tactic == C.TACTIC_PINCER then
local spread = 12000.0 + variation * 5000.0
local approach = rng * 0.45
local merge_off = spread * 0.30
plan.wp_f1 = { x=mid_x+ax*approach+px*spread, z=mid_z+az*approach+pz*spread, y=tgt.y }
plan.wp_f2 = { x=mid_x+ax*approach-px*spread, z=mid_z+az*approach-pz*spread, y=tgt.y }
plan.merge_f1 = { x=tgt.x-ax*3000+px*merge_off, z=tgt.z-az*3000+pz*merge_off, y=tgt.y }
plan.merge_f2 = { x=tgt.x-ax*3000-px*merge_off, z=tgt.z-az*3000-pz*merge_off, y=tgt.y }
elseif tactic == C.TACTIC_HIGH_LOW then
local vert = 3000.0 + variation * 1500.0
local approach = rng * 0.50
local side_off = 2000.0
plan.wp_f1 = { x=mid_x+ax*approach+px*side_off, z=mid_z+az*approach+pz*side_off, y=tgt.y-500.0 }
plan.wp_f2 = { x=mid_x+ax*approach-px*side_off, z=mid_z+az*approach-pz*side_off, y=tgt.y+vert }
plan.merge_f1 = { x=tgt.x-ax*3000+px*side_off, z=tgt.z-az*3000+pz*side_off, y=plan.wp_f1.y }
plan.merge_f2 = { x=tgt.x-ax*3000-px*side_off, z=tgt.z-az*3000-pz*side_off, y=plan.wp_f2.y }
elseif tactic == C.TACTIC_STAGGER then
local lag = 8000.0 + variation * 3000.0
local lead_dist = rng * 0.85
plan.wp_f1 = { x=mid_x+ax*lead_dist, z=mid_z+az*lead_dist, y=tgt.y }
plan.wp_f2 = { x=mid_x+ax*(lead_dist-lag), z=mid_z+az*(lead_dist-lag), y=tgt.y }
plan.merge_f1 = { x=plan.wp_f1.x, z=plan.wp_f1.z, y=plan.wp_f1.y }
plan.merge_f2 = { x=plan.wp_f2.x, z=plan.wp_f2.z, y=plan.wp_f2.y }
elseif tactic == C.TACTIC_TRAIL then
local lag = 3000.0 + variation * 2000.0
local lead_dist = rng * 0.85
local side_off = 500.0
plan.wp_f1 = { x=mid_x+ax*lead_dist, z=mid_z+az*lead_dist, y=tgt.y }
plan.wp_f2 = { x=mid_x+ax*(lead_dist-lag)+px*side_off, z=mid_z+az*(lead_dist-lag)+pz*side_off, y=tgt.y }
plan.merge_f1 = { x=plan.wp_f1.x, z=plan.wp_f1.z, y=plan.wp_f1.y }
plan.merge_f2 = { x=plan.wp_f2.x, z=plan.wp_f2.z, y=plan.wp_f2.y }
elseif tactic == C.TACTIC_GIRAFFE then
-- ── GIRAFFE: Irak/Iran-Doktrin (Mirage F1 vs F-14A) ──────────────
--
-- F1 = Decoy: normale Höhe, direkter Anflug, bindet AWG-9 Radar.
-- Zieht Phoenixe auf sich — gibt F2 Zeit für Pull-up.
--
-- F2 = Killer: Nap-of-Earth (~300-600m AGL/MSL) weit seitlich versetzt.
-- Nutzt Groundclutter um AWG-9 Look-Down zu degradieren.
-- Nähert sich bis 10km vor Ziel, dann Pull-up und Schuss.
-- Seitlicher Versatz ~8-12km damit F2 nicht im Radarsektor
-- des AWG-9 liegt während F1 den Lock hält.
--
-- Historisch: Iran-Irak Krieg 1982-88. Irakische Mirage F1EQ nutzten
-- dieses Profil gegen F-14A/AIM-54 Phoenix — der AWG-9 hatte trotz
-- Look-Down-Fähigkeit bei sehr tiefen Zielen über unebenen Terrain
-- (Zagros-Berge, Khuzestan-Ebene) erhöhte Clutter-Probleme.
--
-- Höhen: neutral (0.0) — Lua addiert AltOffset + terrain-awareness.
-- Mindesthöhe 300m MSL wird am Ende geclampt.
local noe_alt = 300.0 + variation * 300.0 -- 300-600m MSL (Nap-of-Earth)
local side_off = 8000.0 + variation * 4000.0 -- 8-12km seitlich (aus AWG-9 Sektor)
local approach = rng * 0.60 -- F1 direkter Anflug, 60% des Weges
local noe_dist = rng * 0.75 -- F2 nähert sich weiter ran vor Pull-up
plan.wp_f1 = { x=mid_x+ax*approach+px*2000, z=mid_z+az*approach+pz*2000, y=tgt.y }
plan.merge_f1 = { x=tgt.x-ax*5000+px*2000, z=tgt.z-az*5000+pz*2000, y=tgt.y }
plan.wp_f2 = { x=mid_x+ax*noe_dist-px*side_off, z=mid_z+az*noe_dist-pz*side_off, y=noe_alt, absolute_alt=true }
plan.merge_f2 = { x=tgt.x-ax*10000-px*side_off*0.3, z=tgt.z-az*10000-pz*side_off*0.3, y=tgt.y }
else
plan.wp_f1 = { x=tgt.x, z=tgt.z, y=tgt.y }
plan.wp_f2 = { x=tgt.x, z=tgt.z, y=tgt.y }
plan.merge_f1 = { x=tgt.x, z=tgt.z, y=tgt.y }
plan.merge_f2 = { x=tgt.x, z=tgt.z, y=tgt.y }
end
clamp_alt(plan.wp_f1)
if tactic == C.TACTIC_GIRAFFE then
clamp_alt_noe(plan.wp_f2)
else
clamp_alt(plan.wp_f2)
end
clamp_alt(plan.merge_f1)
clamp_alt(plan.merge_f2)
return plan
end
-- ─────────────────────────────────────────────────────────────
-- computeSplitDCS (DCS-Koordinaten Ein/Aus)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.computeSplitDCS(f1, f2, tgt, tactic, variation)
local function dcs2gci(u)
return { x=u.z, z=u.x, y=u.y, vx=u.vz or 0, vz=u.vx or 0, speed=u.spd or 0 }
end
local function gci2dcs(pt)
return { x=pt.z, z=pt.x, y=pt.y, absolute_alt=pt.absolute_alt }
end
local tgt_gci = { x=tgt.z, z=tgt.x, y=tgt.y }
local plan = REDGCI_KERNEL.computeSplit(
dcs2gci(f1), dcs2gci(f2), tgt_gci, tactic, variation)
plan.wp_f1 = gci2dcs(plan.wp_f1)
plan.wp_f2 = gci2dcs(plan.wp_f2)
plan.merge_f1 = gci2dcs(plan.merge_f1)
plan.merge_f2 = gci2dcs(plan.merge_f2)
return plan
end
-- ─────────────────────────────────────────────────────────────
-- buildTransmission (Port von gci_build_transmission)
--
-- ctx/prev Felder: state, prev_state, ticks_in_state, range,
-- aspect_angle, closure_rate, altitude_delta, fuel_fraction
-- sol Felder: heading_deg, time_to_intercept, target_alt,
-- range, aspect_angle, weapons_free
-- Rückgabe: { token_str, delay_sec, weapons_free, silence }
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.buildTransmission(ctx, prev, sol)
local C = REDGCI_KERNEL.C
local tx = {
token_str = "",
delay_sec = REDGCI_KERNEL.clamp(
REDGCI_KERNEL.randDelay(C.DELAY_MIN, C.DELAY_MAX), 3.0, 8.0),
weapons_free = sol.weapons_free,
silence = false,
priority = 50,
}
local hdg_i = math.floor((sol.heading_deg or 0) + 0.5)
local alt_i = math.floor((sol.target_alt or 0) / 100.0 + 0.5) * 100
local rng_km = math.floor((ctx.range or 0) / 1000.0 + 0.5)
local aspect_i = math.floor((ctx.aspect_angle or 0) + 0.5)
local delay = tx.delay_sec
local state = ctx.state or "VECTOR"
local prev_state = prev.state or "VECTOR"
local ticks = ctx.ticks_in_state or 0
local function emit(fmt, ...)
tx.token_str = string.format(fmt, ...)
end
if state == "VECTOR" then
if prev_state ~= "VECTOR" then
local tti_m = math.floor((sol.time_to_intercept or 0) / 60.0)
local tti_s = math.floor(sol.time_to_intercept or 0) % 60
if tti_m > 0 then
emit("VECTOR_WITH_TTI|hdg=%d|alt=%d|rng=%d|tti_m=%d|tti_s=%d|delay=%.1f",
hdg_i, alt_i, rng_km, tti_m, tti_s, delay)
else
emit("VECTOR|hdg=%d|alt=%d|rng=%d|delay=%.1f",
hdg_i, alt_i, rng_km, delay)
end
else
local hdg_delta = math.abs((sol.heading_deg or 0) - (ctx.aspect_angle or 0))
if hdg_delta < 5.0 and ticks > 3 then
tx.silence = true
else
emit("VECTOR|hdg=%d|alt=%d|rng=%d|delay=%.1f",
hdg_i, alt_i, rng_km, delay)
end
end
elseif state == "COMMIT" then
if prev_state ~= "COMMIT" then
tx.priority = 100
emit("COMMIT_FIRST|hdg=%d|alt=%d|rng=%d|aspect=%d|delay=%.1f",
hdg_i, alt_i, rng_km, aspect_i, delay)
elseif ticks == 6 then
emit("COMMIT_NO_LOCK|hdg=%d|rng=%d|aspect=%d|delay=%.1f",
hdg_i, rng_km, aspect_i, delay)
elseif ticks > 8 then
emit("COMMIT_NUDGE|hdg=%d|aspect=%d|delay=%.1f",
hdg_i, aspect_i, delay)
else
tx.silence = true
end
elseif state == "RADAR_CONTACT" then
if prev_state ~= "RADAR_CONTACT" then
if sol.weapons_free then
tx.priority = 100
emit("RADAR_LOCK_WF|rng=%d|delay=%.1f", rng_km, delay)
tx.weapons_free = true
else
emit("RADAR_LOCK_HOLD|rng=%d|delay=%.1f", rng_km, delay)
end
elseif not sol.weapons_free
and (ctx.range or 0) < C.WF_RANGE_MAX
and (ctx.aspect_angle or 0) > C.ASPECT_REAR_ATTACK
and prev_state == "RADAR_CONTACT" then
emit("RADAR_WF_NOW|rng=%d|delay=%.1f", rng_km, delay)
tx.priority = 100
tx.weapons_free = true
else
tx.silence = true
end
elseif state == "VISUAL" then
if prev_state ~= "VISUAL" then
emit("VISUAL_CONFIRM|rng=%d|delay=%.1f", rng_km, delay)
tx.weapons_free = true
else
tx.silence = true
end
elseif state == "NOTCH" then
if prev_state ~= "NOTCH" then
emit("NOTCH_ENTRY|delay=1.5")
elseif ticks % 8 == 0 then
emit("NOTCH_UPDATE|rng=%d|aspect=%d|delay=%.1f",
rng_km, aspect_i, delay)
else
tx.silence = true
end
elseif state == "ABORT" then
tx.delay_sec = 1.5
if (ctx.fuel_fraction or 1.0) < C.FUEL_BINGO then
emit("ABORT_BINGO|hdg=%d|delay=1.5", hdg_i)
else
emit("ABORT_THREAT|hdg=%d|delay=1.5", hdg_i)
end
else
tx.silence = true
end
return tx
end
-- ─────────────────────────────────────────────────────────────
-- mergeTransition (Port von gci_merge_transition)
--
-- ctx Felder: phase, ticks_in_phase, range, bearing_to_target,
-- closure_rate, altitude_delta, pass_count, radar_lost
-- Rückgabe: phase string
-- "ENTRY"|"OVERSHOOT"|"SEPARATION"|"REATTACK"|"LOST"|"SPLASH"
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.mergeTransition(ctx, prev)
if ctx.radar_lost then return "LOST" end
if (ctx.closure_rate or 0) < -30.0 and (ctx.range or 0) > 3000.0 then
return "SEPARATION"
end
local brg = ctx.bearing_to_target or 0
if brg > 100.0 and brg < 260.0 and (ctx.range or 0) < 5000.0 then
return "OVERSHOOT"
end
if ctx.phase == "SEPARATION" and (ctx.pass_count or 0) < 3 then
return "REATTACK"
end
return ctx.phase
end
-- ─────────────────────────────────────────────────────────────
-- buildMergeTransmission (Port von gci_build_merge_transmission)
-- ─────────────────────────────────────────────────────────────
function REDGCI_KERNEL.buildMergeTransmission(ctx, prev)
local C = REDGCI_KERNEL.C
local tx = {
token_str = "",
delay_sec = REDGCI_KERNEL.clamp(
REDGCI_KERNEL.randDelay(C.DELAY_MERGE_MIN, C.DELAY_MERGE_MAX), 2.0, 5.0),
weapons_free = false,
silence = false,
priority = 50,
}
local brg = math.floor((ctx.bearing_to_target or 0) + 0.5)
local rng_km = math.floor((ctx.range or 0) / 1000.0 + 0.5)
local delay = tx.delay_sec
local phase = ctx.phase or "ENTRY"
local prev_phase = prev.phase or "ENTRY"
local ticks = ctx.ticks_in_phase or 0
local dir_rl = ((ctx.bearing_to_target or 0) < 180.0) and "right" or "left"
local alt_d = ctx.altitude_delta or 0
local alt_rel = ""
if alt_d > 400.0 then alt_rel = "low"
elseif alt_d < -400.0 then alt_rel = "high"
end
local function emit(fmt, ...)
tx.token_str = string.format(fmt, ...)
end
if phase == "ENTRY" then
tx.priority = 100
emit("MERGE_ENTRY|brg=%d|dir_rl=%s|delay=%.1f", brg, dir_rl, delay)
elseif phase == "OVERSHOOT" then
emit("MERGE_OVERSHOOT|brg=%d|dir_rl=%s|alt_rel=%s|delay=%.1f",
brg, dir_rl, alt_rel, delay)
elseif phase == "SEPARATION" then
if (ctx.pass_count or 0) < 3 then
emit("MERGE_REATTACK|brg=%d|rng=%d|delay=%.1f", brg, rng_km, delay)
else
emit("ABORT_THREAT|hdg=%d|delay=%.1f", brg, delay)
end
elseif phase == "REATTACK" then
if prev_phase ~= "REATTACK" or ticks % 3 == 0 then
emit("MERGE_REATTACK|brg=%d|rng=%d|delay=%.1f", brg, rng_km, delay)
else
tx.silence = true
end
elseif phase == "LOST" then
if prev_phase ~= "LOST" or ticks == 4 then
emit("MERGE_LOST|brg=%d|rng=%d|delay=%.1f", brg, rng_km, delay)
else
tx.silence = true
end
elseif phase == "SPLASH" then
tx.delay_sec = 1.5
tx.priority = 100
emit("MERGE_SPLASH|delay=1.5")
else
tx.silence = true
end
return tx
end
-------------------------------------------------------------------------------
-- END of Class
-------------------------------------------------------------------------------
@@ -0,0 +1,968 @@
--- **Functional** - Enhanced Warsaw Pact GCI 2v2 Controller.
--
-- ## Main Features:
--
-- * Guide AI and human pilots in Warsaw Pact Style in 2v2 tactics.
-- * Advanced Tactics for Groups.
-- * Many additional events that the mission designer can hook into.
--
-- ===
--
-- ## Example Missions:
--
-- Demo missions can be found on [GitHub](https://github.com/FlightControl-Master/MOOSE_MISSIONS/).
--
-- ===
--
-- ### Author: **Applevangelist**
--
-- ===
-- @module Functional.RedGCI2v2
-- @image Func_RedGCI.png
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--- REDGCI2v2 class
-- @type REDGCI2v2
-- @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 (REDGCI_DISPATCHER)
--
-- 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 REDGCI2v2
REDGCI2v2 = {}
REDGCI2v2.ClassName = "REDGCI2v2"
REDGCI2v2.version = "1.0.0"
-- ─────────────────────────────────────────────────────────────
-- Tactic weights (sum must equal 100)
-- ─────────────────────────────────────────────────────────────
--- @type REDGCI2v2.TacticWeights
-- @field #string PINCER
-- @field #string HIGH_LOW
-- @field #string STAGGER
-- @field #string TRAIL
REDGCI2v2.TacticWeights = {
["PINCER"] = 35, -- split left/right, classic bracket
["HIGH_LOW"] = 25, -- one high, one low
["STAGGER"] = 20, -- trail with BVR timing offset
["TRAIL"] = 10, -- tight trail, one shooter one support
["GIRAFFE"] = 10, -- Irak/Iran-Doktrin: F1 Decoy hoch, F2 NOE im Groundclutter
}
--- Tactic commit range — split geometry applied at this range
-- @field #number COMMIT_RANGE
REDGCI2v2.COMMIT_RANGE = 30000 -- metres (mirrors GCI_RANGE_COMMIT)
--- Split lateral offset for PINCER (metres, randomised ±variation)
-- @field #number PINCER_OFFSET
REDGCI2v2.PINCER_OFFSET = 10000 -- 10 km
--- Altitude offsets for HIGH_LOW (metres relative to target)
-- @field #number HIGH_ALT_OFFSET
-- @field #number LOW_ALT_OFFSET
REDGCI2v2.HIGH_ALT_OFFSET = 3500
REDGCI2v2.LOW_ALT_OFFSET = -200
--- Trail/Stagger separation distances (metres)
-- @field #number TRAIL_SEP
-- @field #number STAGGER_SEP
REDGCI2v2.TRAIL_SEP = 4000
REDGCI2v2.STAGGER_SEP = 13000
--- Formation lateral offset during VECTOR phase (metres)
-- @field #number FORMATION_OFFSET
REDGCI2v2.FORMATION_OFFSET = 2000
--- Split range for tactics VECTOR phase (metres)
-- @field #number SPLIT_RANGE
REDGCI2v2.SPLIT_RANGE = 60000 -- 60km pre-COMMIT split
--- How many ticks the tactic override point is held before reverting
-- to normal intercept guidance (default 7 ticks = 70s at 10s interval)
-- @field #number OVERRIDE_TICKS
REDGCI2v2.OVERRIDE_TICKS = 7
-- ─────────────────────────────────────────────────────────────
-- Constructor
-- ─────────────────────────────────────────────────────────────
--- Create a new REDGCI2v2 two-ship flight.
-- @param #REDGCI2v2 self
-- @param #string Fighter1Group DCS group name — lead aircraft
-- @param #string Fighter2Group DCS group name — wingman
-- @param #string Target1Group DCS group name — primary target
-- @param #string Target2Group DCS group name — secondary target (or nil for 2v1)
-- @param #string FlightCallsign Radio callsign prefix (e.g. "Сокол-1")
-- @param #number Coalition coalition.side.RED or BLUE
-- @return #REDGCI2v2 self
function REDGCI2v2:New(Fighter1Group, Fighter2Group,
Target1Group, Target2Group,
FlightCallsign, Coalition)
local self = BASE:Inherit(self, FSM:New()) --#REDGCI2v2
self.lid = string.format("REDGCI2v2 (%s) | ", FlightCallsign or "GCI2v2")
-- ── Identity ──────────────────────────────────────────────
self.Fighter1Group = Fighter1Group or "Fighter-1"
self.Fighter2Group = Fighter2Group or "Fighter-2"
self.Target1Group = Target1Group or "Target-1"
self.Target2Group = Target2Group -- nil = 2v1 mode
self.FlightCallsign = FlightCallsign or "Сокол-1"
self.Coalition = Coalition or coalition.side.RED
-- Callsigns for each aircraft (appended -1 / -2)
local Unit1 = GROUP:FindByName(self.Fighter1Group):GetUnit(1)
local Unit2 = GROUP:FindByName(self.Fighter2Group):GetUnit(1)
local Callsign1 = (Unit1 and Unit1:IsAlive()) and Unit1:GetCallsign() or FlightCallsign .. "-1"
local Callsign2 = (Unit2 and Unit2:IsAlive()) and Unit2:GetCallsign() or FlightCallsign .. "-2"
self.Callsign1 = Callsign1
self.Callsign2 = Callsign2
-- ── Shared configuration defaults (mirrored to sub-instances) ──
self.Locale = "ru"
self.TickInterval = 10.0
self.TxRepeatInterval = 30.0
self.IsAIPlane = true
self.HomeBaseName = nil
self.AltOffset = -700
self.Debug = false
self._missilerangeflag = 2
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.FreqOffset = 0.5
-- ── Tactic state ──────────────────────────────────────────
self._tactic = nil -- chosen at COMMIT
self._variation = 0.0 -- 0.01.0 random variation seed
self._tactic_applied = false -- split WPs already pushed?
self._assignment = nil -- { [1]=targetGroupName, [2]=targetGroupName }
-- ── Sub-instances (created in onafterStart) ───────────────
self._gci1 = nil --#REDGCI
self._gci2 = nil --#REDGCI
-- ── FSM ───────────────────────────────────────────────────
self:SetStartState("Stopped")
self:AddTransition("Stopped", "Start", "Running")
self:AddTransition("Running", "Status", "Running")
self:AddTransition("Running", "Stop", "Stopped")
self:I(self.lid .. "v" .. REDGCI2v2.version .. " created.")
return self
end
-- ─────────────────────────────────────────────────────────────
-- User API (mirrors REDGCI for convenience)
-- ─────────────────────────────────────────────────────────────
--- Set Locale
--@param #REDGCI2v2 self
--@param #string Locale Locale to be set. Known ones are "ru", "en" and "de".
function REDGCI2v2:SetLocale(Locale)
self.Locale = Locale or "ru"
return self
end
--- Set AI Mode
--@param #REDGCI2v2 self
--@param #boolean IsAI True for AI.
--@param #string HomeBaseName Name of the home plate.
function REDGCI2v2:SetAIMode(IsAI, HomeBaseName)
self.IsAIPlane = IsAI ~= false
self.HomeBaseName = HomeBaseName
return self
end
--- Set SRS
--@param #REDGCI2v2 self
--@param #string Path
--@param #number Frequency
--@param #number Modulation
--@param #string Culture
--@param #string Voice
--@param #string Port
--@param #string Speed
function REDGCI2v2: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
return self
end
--- Set SRS Provider
--@param #REDGCI2v2 self
--@param #string Provider
function REDGCI2v2:SetSRSProvider(Provider)
self.SRSProvider = Provider or MSRS.Provider.GOOGLE
return self
end
--- Set SRS Voice Speaker for Hound/Piper
--@param #REDGCI2v2 self
--@param #number Speaker Speaker number, e.g. 11 for Speaker "318 (11)"
function REDGCI2v2:SetSRSPiperSpeaker(Speaker)
self.SRSSpeaker = Speaker or 0
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 #REDGCI2v2 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 #REDGCI2v2 self
function REDGCI2v2: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 #REDGCI2v2 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 #REDGCI2v2 self
function REDGCI2v2: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 Tick Interval
--@param #REDGCI2v2 self
--@param #number Seconds Default 10.0
function REDGCI2v2:SetTickInterval(Seconds)
self.TickInterval = Seconds or 10.0
return self
end
--- Set the AI weapons-free range threshold in metres.
-- Weapons free is declared when the C kernel wf flag is true OR (AI mode AND
-- state is RADAR_CONTACT AND range <= WFRange). Set to 0 to disable the
-- Lua-side override and rely solely on the C kernel.
-- @param #REDGCI2v2 self
-- @param #number Meters Default 20000
-- @return #REDGCI2v2 self
function REDGCI2v2:SetWFRange(Meters)
self.WFRange = Meters or 20000
return self
end
--- Set minimum seconds between identical transmissions.
-- @param #REDGCI2v2 self
-- @param #number Seconds Default 30.0
-- @return #REDGCI2v2 self
function REDGCI2v2:SetTxRepeatInterval(Seconds)
self.TxRepeatInterval = Seconds or 30.0
return self
end
--- Set Debug
--@param #REDGCI2v2 self
--@param #boolean
function REDGCI2v2:SetDebug(OnOff)
self.Debug = OnOff ~= false
return self
end
--- Set AltOffset in meters. Negative for shoot-up, positive for shoot-down
--@param #REDGCI2v2 self
--@param #number Meters
function REDGCI2v2:SetAltOffset(Meters)
self.AltOffset = Meters or -700
return self
end
--- Override tactic selection weights.
-- @param #REDGCI2v2 self
-- @param #table Weights { PINCER=N, HIGH_LOW=N, STAGGER=N, TRAIL=N } (sum = 100)
-- @return #REDGCI2v2 self
function REDGCI2v2:SetTacticWeights(Weights)
self.TacticWeights = Weights
return self
end
--- Set range on which AI will prefer to fire missiles.
-- MAX_RANGE = 0, NEZ_RANGE = 1, HALF_WAY_RMAX_NEZ = 2, TARGET_THREAT_EST = 3, RANDOM_RANGE = 4. Defaults to 2.
-- @param #REDGCI2v2 self
-- @param #number Flag The behavior to set.
-- @return #REDGCI2v2 self
function REDGCI2v2:SetMissileFiringFlag(Flag)
self._missilerangeflag = Flag or 2
return self
end
-- ─────────────────────────────────────────────────────────────
-- Internal helpers
-- ─────────────────────────────────────────────────────────────
--- [Internal]
--@param #REDGCI2v2 self
--@param #string msg
function REDGCI2v2:_Log(msg)
if self.Debug then
env.info(self.lid .. msg)
end
end
--- [Internal] Build a configured REDGCI sub-instance.
-- @param #REDGCI2v2 self
-- @param #string FighterGroup
-- @param #string TargetGroup
-- @param #string Callsign
-- @param #number FreqOffSet (Optional) Frequency offset for multiple groups.
-- @return #REDGCI instance (not started)
function REDGCI2v2:_MakeGCI(FighterGroup, TargetGroup, Callsign, FreqOffSet)
local grp = GROUP:FindByName(FighterGroup)
local IsAiPlane = true
if grp and grp:GetPlayerName() ~= nil then IsAiPlane = false end
local FreqOffSet = FreqOffSet or 0
local Frequency = self.SRSFreq -- + FreqOffSet
local gci = REDGCI:New(FighterGroup, TargetGroup, Callsign, self.Coalition)
gci:SetLocale(self.Locale)
gci:SetAIMode(IsAiPlane, self.HomeBaseName)
gci:SetSRS(self.SRSPath, Frequency, self.SRSMod, self.SRSCulture, self.SRSVoice, self.SRSPort, self.SRSSpeed)
if self.SRSSpeaker then
gci:SetSRSPiperSpeaker(self.SRSSpeaker)
end
if self.SRSProvider then
gci:SetSRSProvider(self.SRSProvider)
end
if self.WFRange then
gci:SetWFRange(self.WFRange)
end
gci:SetTickInterval(self.TickInterval)
gci:SetTxRepeatInterval(self.TxRepeatInterval)
gci:SetAltOffset(self.AltOffset)
gci:SetDebug(self.Debug)
gci:SetMissileFiringFlag(self._missilerangeflag)
return gci
end
--- [Internal] Range between two unit-data tables (horizontal).
-- @param #REDGCI2v2 self
-- @param #table A
-- @param #table B
-- @return #number metres
function REDGCI2v2:_Range(A, B)
local dx = B.x - A.x
local dz = B.z - A.z
return math.sqrt(dx*dx + dz*dz)
end
--- [Internal] Weighted-random tactic selection.
-- @param #REDGCI2v2 self
-- @return #string tactic key
function REDGCI2v2:_PickTactic()
local order = { "PINCER", "HIGH_LOW", "STAGGER", "TRAIL", "GIRAFFE" }
if self._Fixed_Tactic then return self._Fixed_Tactic end
if UTILS.lcg == nil then
UTILS.LCGRandomSeed(timer.getTime()*math.random())
end
--local roll = UTILS.LCGRandomSeed(timer.getTime())
local roll = math.floor(UTILS.LCGRandom()*100)
local accum = 0
for _,tactic in ipairs(order) do
local weight = REDGCI2v2.TacticWeights[tactic] or 0
accum = accum + weight
self:T(self.lid.."_PickTactic "..string.format("Roll %d | Tactic %s | Weight %s | Chosen %s",roll,tactic,weight,tostring(roll <= accum)))
if roll <= accum then
return tactic
end
end
return "PINCER" -- fallback
end
--- [Internal] Target assignment — greedy minimum range 2×2.
-- Sets self._assignment = { [1]=groupName, [2]=groupName }
-- @param #REDGCI2v2 self
-- @param #table F1 fighter-1 unit data
-- @param #table F2 fighter-2 unit data
-- @param #table T1 target-1 unit data (or nil)
-- @param #table T2 target-2 unit data (or nil)
function REDGCI2v2:_AssignTargets(F1, F2, T1, T2)
if not T2 then
-- 2v1: both fighters on single target
self._assignment = {
[1] = self.Target1Group,
[2] = self.Target1Group,
}
self:_Log("Assignment: 2v1 — both on " .. self.Target1Group)
return
end
local d11 = self:_Range(F1, T1)
local d22 = self:_Range(F2, T2)
local d12 = self:_Range(F1, T2)
local d21 = self:_Range(F2, T1)
if d11 + d22 <= d12 + d21 then
self._assignment = { [1] = self.Target1Group, [2] = self.Target2Group }
self:_Log(string.format("Assignment: F1→T1 (%.0fm) F2→T2 (%.0fm)", d11, d22))
else
self._assignment = { [1] = self.Target2Group, [2] = self.Target1Group }
self:_Log(string.format("Assignment: F1→T2 (%.0fm) F2→T1 (%.0fm)", d12, d21))
end
end
--- [Internal] Target assignment — greedy minimum range 2×2.
-- Sets self._assignment = { [1]=groupName, [2]=groupName }
-- @param #REDGCI2v2 self
function REDGCI2v2:_SetDebugMenuTactics()
-- { "PINCER", "HIGH_LOW", "STAGGER", "TRAIL", "GIRAFFE" }
--local root = missionCommands.addSubMenuForCoalition(self.Coalition, "GCI")
local root2 = missionCommands.addSubMenuForCoalition(self.Coalition, "GCI Tactic")
missionCommands.addCommandForCoalition(self.Coalition, "PINCER", root2,
function()
self._Fixed_Tactic = "PINCER"
self:_Log("Debug: PINCER Tactic")
end)
missionCommands.addCommandForCoalition(self.Coalition, "HIGH_LOW", root2,
function()
self._Fixed_Tactic = "HIGH_LOW"
self:_Log("Debug: HIGH_LOW Tactic")
end)
missionCommands.addCommandForCoalition(self.Coalition, "TRAIL", root2,
function()
self._Fixed_Tactic = "TRAIL"
self:_Log("Debug: TRAIL Tactic")
end)
missionCommands.addCommandForCoalition(self.Coalition, "STAGGER", root2,
function()
self._Fixed_Tactic = "STAGGER"
self:_Log("Debug: STAGGER Tactic")
end)
missionCommands.addCommandForCoalition(self.Coalition, "GIRAFFE", root2,
function()
self._Fixed_Tactic = "GIRAFFE"
self:_Log("Debug: GIRAFFE Tactic")
end)
end
--- [Internal]Compute perpendicular vector (90° left of heading dx,dz), normalised.
-- @param #REDGCI2v2 self
-- @return #number px, #number pz
function REDGCI2v2._perp_left(dx, dz)
local len = math.sqrt(dx*dx + dz*dz)
if len < 1 then return 0, 0 end
return -dz/len, dx/len
end
--- [Internal]Apply split tactic geometry and push waypoints to both fighters.
-- Called once at COMMIT transition.
-- @param #REDGCI2v2 self
-- @param #table F1 fighter-1 unit data
-- @param #table F2 fighter-2 unit data
-- @param #table TgtMid geometric midpoint of active targets
function REDGCI2v2:_ApplyTactic(F1, F2, TgtMid)
-- Map Lua tactic name → C-kern TacticType int
-- (must match TacticType enum in pursuit_solver.h)
local TACTIC_INT = {
PINCER = 0,
HIGH_LOW = 1,
STAGGER = 2,
TRAIL = 3,
GIRAFFE = 4,
}
local tactic_int = TACTIC_INT[self._tactic] or 0
-- Call C-kernel geometry solver
-- Returns 12 DCS-coordinate values (x=Nord, z=Ost, y=Höhe)
local plan = REDGCI_KERNEL.computeSplitDCS(F1, F2, TgtMid, tactic_int, self._variation)
local wp1x, wp1z, wp1y = plan.wp_f1.x, plan.wp_f1.z, plan.wp_f1.y
local wp2x, wp2z, wp2y = plan.wp_f2.x, plan.wp_f2.z, plan.wp_f2.y
local mp1x, mp1z, mp1y = plan.merge_f1.x, plan.merge_f1.z, plan.merge_f1.y
local mp2x, mp2z, mp2y = plan.merge_f2.x, plan.merge_f2.z, plan.merge_f2.y
local wp2_absolute = plan.wp_f2.absolute_alt or false
-- altitudes
local alt1 = self._gci1.AltOffset or 0
local alt2 = wp2_absolute and 0 or (self._gci2.AltOffset or 0)
self:T(self.lid .. string.format(
"_ApplyTactic: %s var=%.2f | F1→(%.0f,%.0f,%.0fm) F2→(%.0f,%.0f,%.0fm)",
self._tactic, self._variation,
wp1x, wp1z, wp1y + alt1,
wp2x, wp2z, wp2y + alt2))
-- Set _wp_override on sub-instances.
-- REDGCI:_ResolveTarget() checks this each tick and uses it instead
-- of ip_x/ip_z/ip_y for OVERRIDE_TICKS ticks, then reverts.
self._gci1._wp_override = {
x = wp1x,
z = wp1z,
y = wp1y + alt1,
ticks = REDGCI2v2.OVERRIDE_TICKS,
absolute_alt=false,
}
self._gci2._wp_override = {
x = wp2x,
z = wp2z,
y = wp2y + alt2,
ticks = REDGCI2v2.OVERRIDE_TICKS,
absolute_alt=wp2_absolute,
}
self._tactic_applied = true
if self.Debug then
trigger.action.outTextForCoalition(
self.Coalition,
string.format("[GCI2v2] %s | F1→(%.0f,%.0f,%.0fm) F2→(%.0f,%.0f,%.0fm)",
self._tactic,
wp1x, wp1z, wp1y + alt1,
wp2x, wp2z, wp2y + alt2), 10)
end
end
--- [Internal] Compute geometric midpoint of active targets.
-- @param #table T1 unit data or nil
-- @param #table T2 unit data or nil
-- @return #table { x, y, z }
function REDGCI2v2._tgt_midpoint(T1, T2)
if T1 and T2 then
return {
x = (T1.x + T2.x) * 0.5,
y = (T1.y + T2.y) * 0.5,
z = (T1.z + T2.z) * 0.5,
}
elseif T1 then
return { x=T1.x, y=T1.y, z=T1.z }
elseif T2 then
return { x=T2.x, y=T2.y, z=T2.z }
end
end
--- [Internal] Formation VECTOR waypoint: both fighters get a shared intercept vector
-- with a lateral offset to maintain 2 km spacing.
-- @param #REDGCI2v2 self
-- @param #table F1 fighter-1 unit data
-- @param #table F2 fighter-2 unit data
-- @param #table TgtMid target midpoint
function REDGCI2v2:_PushFormationVector(F1, F2, TgtMid)
local dx = TgtMid.x - F1.x
local dz = TgtMid.z - F1.z
local px, pz = REDGCI2v2._perp_left(dx, dz)
-- Apply formation offset to fighter-2 only (lead stays on centreline)
local wp_y = TgtMid.y + self.AltOffset
local cruise1 = math.max(F1.spd, 200)
local cruise2 = math.max(F2.spd, 200)
-- Rolling WP clamped to lookahead distance
local wp1x, wp1z, wp1y = self._gci1:_ComputeRollingWaypoint(F1, TgtMid.x, TgtMid.z, wp_y)
local wp2x, wp2z, wp2y = self._gci2:_ComputeRollingWaypoint(F2,
TgtMid.x + px * REDGCI2v2.FORMATION_OFFSET,
TgtMid.z + pz * REDGCI2v2.FORMATION_OFFSET,
wp_y)
self._gci1:_PushWaypoint(wp1x, wp1z, wp1y, cruise1)
self._gci2:_PushWaypoint(wp2x, wp2z, wp2y, cruise2)
end
-- ─────────────────────────────────────────────────────────────
-- FSM handlers
-- ─────────────────────────────────────────────────────────────
--- [Internal] Start — build sub-instances and start them.
-- @param #REDGCI2v2 self
function REDGCI2v2:onafterStart(From, Event, To)
self:T(self.lid .. "Starting...")
-- Initial target assignment (may be reassigned later)
self._assignment = {
[1] = self.Target1Group,
[2] = self.Target2Group or self.Target1Group,
}
-- Create and start the two REDGCI sub-instances.
-- They manage their own FSM ticks; we drive formation/tactic logic here.
self._gci1 = self:_MakeGCI(self.Fighter1Group, self._assignment[1], self.Callsign1) -- #REDGCI
local FreqOffset = self.FreqOffset or 0.5
--if self.IsAIPlane == true then FreqOffset = 0.5 end
self._gci2 = self:_MakeGCI(self.Fighter2Group, self._assignment[2], self.Callsign2, FreqOffset)
if self.PilotOneCallsign then
self._gci1:SetPilotSRS(self.PilotOneCallsign, self.PilotOneSRSCulture, self.PilotOneSRSVoice, self.PilotOneSRSSpeaker)
end
if self.PilotTwoCallsign then
self._gci2:SetPilotSRS(self.PilotTwoCallsign, self.PilotTwoSRSCulture, self.PilotTwoSRSVoice, self.PilotTwoSRSSpeaker)
end
self._gci1:Start()
self._gci2:Start()
self:T(self.lid .. "Sub-GCI started: " .. self.Callsign1 .. " / " .. self.Callsign2)
-- Gemeinsame SRS Queue — verhindert dass beide Instanzen sich übersprechen
--local shared_queue = MSRSQUEUE:New("REDGCI2v2_" .. self.FlightCallsign)
--self._gci1._srs_queue = shared_queue
--self._gci2._srs_queue = shared_queue
--self:T(self.lid .. "Shared SRS queue injected.")
-- WF Deconfliction Gate — Option C: Winkel-basiert
local gci1 = self._gci1
local gci2 = self._gci2
local flight = self
-- Lead: immer frei
gci1._wf_gate = nil
-- Wingman: WF nur wenn Winkel Lead-Schussvektor → Wingman > 30°
gci2._wf_gate = function(gci)
local F1 = gci1:_GetUnitData(gci1.FighterGroupName)
local F2 = gci:_GetUnitData(gci.FighterGroupName)
local T1 = gci1:_GetUnitData(gci1.TargetGroupName)
if not F1 or not F2 or not T1 then return true end -- kein Daten = nicht blocken
-- Vektor Lead → Ziel
local ax = T1.x - F1.x
local az = T1.z - F1.z
local len_a = math.sqrt(ax*ax + az*az)
-- Vektor Lead → Wingman
local bx = F2.x - F1.x
local bz = F2.z - F1.z
local len_b = math.sqrt(bx*bx + bz*bz)
if len_a < 1 or len_b < 1 then return true end
-- Winkel zwischen beiden Vektoren
local dot = (ax*bx + az*bz) / (len_a * len_b)
dot = math.max(-1.0, math.min(1.0, dot)) -- clamp für acos
local angle_deg = math.deg(math.acos(dot))
local safe = angle_deg > 30.0
if not safe then
flight:I(flight.lid .. string.format(
"WF Gate: Wingman zu nah am Schussvektor (%.1f°) — blocke WF", angle_deg))
end
return safe
end
if self.Debug then
self:_SetDebugMenuTactics()
end
trigger.action.outTextForCoalition(
self.Coalition,
string.format("[GCI] Двухзвенный перехват. %s и %s готовы.",
self.Callsign1, self.Callsign2), 5)
self:__Status(-2)
end
--- [Internal] Main 2v2 coordination tick.
-- Runs in parallel with the two sub-instance ticks.
-- Handles: target reassignment, tactic selection, formation VECTOR, split at COMMIT.
-- @param #REDGCI2v2 self
--- Main 2v2 coordination tick.
-- Handles: target reassignment, tactic selection and split at COMMIT.
-- Does NOT push waypoints during VECTOR — sub-instances handle their own WPs.
-- @param #REDGCI2v2 self
--- Main 2v2 coordination tick.
-- Handles: target reassignment, tactic selection and pre-COMMIT split at SPLIT_RANGE.
-- Does NOT push waypoints during VECTOR — sub-instances handle their own WPs.
-- @param #REDGCI2v2 self
function REDGCI2v2:onafterStatus(From, Event, To)
-- ── Get unit data ─────────────────────────────────────────
local F1 = self._gci1:_GetUnitData(self.Fighter1Group)
local F2 = self._gci2:_GetUnitData(self.Fighter2Group)
-- Stop if both fighters gone
if not F1 and not F2 then
self:T(self.lid .. "Both fighters lost — stopping.")
self:Stop()
return
end
-- ── Target data ───────────────────────────────────────────
local T1 = self._gci1:_GetUnitData(self._assignment[1])
local T2 = self.Target2Group
and self._gci2:_GetUnitData(self._assignment[2])
or nil
-- ── Target reassignment (only in true 2v2) ────────────────
if self.Target2Group then
local alive1 = T1 ~= nil
local alive2 = T2 ~= nil
if not alive1 and alive2 then
if self._assignment[1] ~= self.Target2Group then
self:T(self.lid .. "Target-1 destroyed — F1 → Target-2")
self._assignment[1] = self.Target2Group
self._gci1.TargetGroupName = self.Target2Group
-- Nur zurücksetzen wenn noch genug Range für sinnvollen Split
local F1 = self._gci1:_GetUnitData(self.Fighter1Group)
local T2 = self._gci2:_GetUnitData(self._assignment[2])
if F1 and T2 and self:_Range(F1, T2) > REDGCI2v2.SPLIT_RANGE then
self._tactic_applied = false
end
end
elseif alive1 and not alive2 then
if self._assignment[2] ~= self.Target1Group then
self:T(self.lid .. "Target-2 destroyed — F2 → Target-1")
self._assignment[2] = self.Target1Group
self._gci2.TargetGroupName = self.Target1Group
local F2 = self._gci2:_GetUnitData(self.Fighter2Group)
local T1 = self._gci1:_GetUnitData(self._assignment[1])
if F2 and T1 and self:_Range(F2, T1) > REDGCI2v2.SPLIT_RANGE then
self._tactic_applied = false
end
end
elseif not alive1 and not alive2 then
self:T(self.lid .. "All targets destroyed.")
self:Stop()
return
end
-- Re-fetch after possible reassignment
T1 = self._gci1:_GetUnitData(self._assignment[1])
T2 = self._gci2:_GetUnitData(self._assignment[2])
end
-- ── Picture call: count = alle lebenden Ziele ─────────────
-- Jede REDGCI-Instanz hat keinen INTEL-Zugriff, also setzen wir
-- den Count hier zentral: Summe aller lebenden Units in T1+T2.
local total_count = 0
for _, grpname in ipairs({ self.Target1Group, self.Target2Group }) do
if grpname then
local grp = GROUP:FindByName(grpname)
if grp then total_count = total_count + grp:CountAliveUnits() end
end
end
if total_count > 0 then
self._gci1._target_count = total_count
self._gci2._target_count = total_count
end
-- ── Target midpoint ───────────────────────────────────────
local tgt_mid = REDGCI2v2._tgt_midpoint(T1, T2)
-- ── Current states ────────────────────────────────────────
local state1 = self._gci1._prev_state
local state2 = self._gci2._prev_state
-- ── Optimal assignment re-evaluation during VECTOR ────────
if self.Target2Group and
(state1 == "VECTOR" or state1 == nil) and
(state2 == "VECTOR" or state2 == nil) then
if F1 and F2 and T1 and T2 then
self:_AssignTargets(F1, F2, T1, T2)
self._gci1.TargetGroupName = self._assignment[1]
self._gci2.TargetGroupName = self._assignment[2]
end
end
-- ── Pre-COMMIT Split ──────────────────────────────────────
-- Fires during VECTOR at SPLIT_RANGE (default 50km), giving fighters
-- enough room to fly the split geometry before entering COMMIT.
-- PINCER needs ~10km spread, STAGGER ~13km lag — 50km gives plenty of time.
if (not self._tactic_applied) and F1 and F2 and tgt_mid then
-- Use minimum range across both fighters to their targets
local r1 = T1 and self:_Range(F1, T1) or math.huge
local r2 = (T2 and self:_Range(F2, T2))
or (T1 and self:_Range(F2, T1))
or math.huge
local min_range = math.min(r1, r2)
if min_range <= REDGCI2v2.SPLIT_RANGE then
self._tactic = self:_PickTactic()
--local roll = UTILS.LCGRandomSeed(timer.getTime())
self._variation = math.ceil(UTILS.LCGRandom()*100)
self:T(self.lid .. string.format(
"Split at %.0fm — tactic=%s variation=%.2f",
min_range, self._tactic, self._variation))
if self.Debug then
trigger.action.outTextForCoalition(
self.Coalition,
string.format("[GCI2v2] Taktik: %s (%.0f km)",
self._tactic, min_range / 1000), 8)
end
self:_ApplyTactic(F1, F2, tgt_mid)
-- _tactic_applied is set inside _ApplyTactic
end
end
self:__Status(-self.TickInterval)
end
--- [Internal] Stop handler.
-- @param #REDGCI2v2 self
function REDGCI2v2:onafterStop(From, Event, To)
self:T(self.lid .. "Stopped.")
end
-------------------------------------------------------------------------------
-- END of Class
-------------------------------------------------------------------------------
+1 -1
View File
@@ -2588,7 +2588,7 @@ end
-- @return #MSRSQUEUE.Transmission Radio transmission table.
function MSRSQUEUE:NewTransmission(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate,speed,speaker,priority)
self:T({Text=text, Dur=duration, start=tstart, int=interval, sub=subgroups, subt=subtitle, sudb=subduration, F=frequency, M=modulation, G=gender, C=culture, V=voice, Vol=volume, L=label, S=speed, P=priority})
self:T({provider=msrs.provider})
self:I({TEXT=text, PRIO=tostring(priority)})
if self.TransmitOnlyWithPlayers then
if self.PlayerSet and self.PlayerSet:CountAlive() == 0 then
return self
+1 -1
View File
@@ -4262,7 +4262,7 @@ end
-- @return #number Random number between 0 and 1.
function UTILS.LCGRandom()
if UTILS.lcg == nil then
UTILS.LCGRandomSeed()
UTILS.LCGRandomSeed(timer.getTime())
end
UTILS.lcg.seed = (UTILS.lcg.a * UTILS.lcg.seed + UTILS.lcg.c) % UTILS.lcg.m
return UTILS.lcg.seed / UTILS.lcg.m