Capture current mission, AI, map and administration development

Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
2026-10-13 22:52:53 +02:00
parent a951a96edc
commit 6a667d8deb
8104 changed files with 2124945 additions and 2061 deletions
@@ -0,0 +1,365 @@
class ERR_AirPassenger
{
IEntity Actor;
BaseCompartmentSlot Seat;
bool Locked = true;
bool Jumped;
ERR_AssaultParachute Chute;
}
class ERR_AirAssaultTeam
{
SCR_AIGroup Group;
ref array<ref ERR_AirPassenger> Members = {};
ref ERR_AirDropOrder Order;
bool Released;
void Landed() {} // Canopy completion is observed by the owning load for the entire original team.
void Update()
{
if (Released || !Group || !Order || Group.GetPlayerCount(true) > 0) return;
bool survivor;
foreach (ERR_AirPassenger member : Members)
{
if (!ERR_Actors.Alive(member.Actor)) continue;
if (!member.Jumped || CompartmentAccessComponent.GetVehicleIn(member.Actor) || member.Actor.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(member.Actor.GetOrigin()) > 3) return;
survivor = survivor || ERR_Actors.CanFight(member.Actor);
}
if (!survivor) return;
Released = true;
Group.ActivateAI();
Group.SetPermanentLOD(-1);
ERR_HQ.Get().LandAirTeam(Group, Order);
}
}
class ERR_AirDropGeometry
{
static string LastFailure;
static float Along(vector position, vector center, vector direction)
{ vector offset = position - center;
offset[1] = 0;
return vector.Dot(offset, direction);
}
static bool Corridor(vector position, vector center, vector direction, vector velocity)
{
float along = Along(position, center, direction);
vector lateral = position - center - direction * along;
lateral[1] = 0;
velocity[1] = 0;
return along >= -175 && along <= 250 && lateral.Length() <= 45 && velocity.Length() >= 15 && vector.Dot(velocity.Normalized(), direction) >= 0.8;
}
static bool LandingLine(vector center, vector direction)
{
LastFailure = "clear";
for (int step = -4; step <= 5; step++)
{
vector point = ERR_Actors.Ground(center + direction * (step * 50));
if (point[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) { LastFailure = "water/shore"; return false; }
if (!ERR_HQRoadIntercept.Standable(point)) { LastFailure = "landing ground not standable"; return false; }
TraceBox trace = new TraceBox();
trace.Start = point + "0 3 0";
trace.End = point + "0 190 0";
trace.Mins = "-4 -1 -4";
trace.Maxs = "4 1 4";
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) { LastFailure = "obstructed descent column"; return false; }
}
return true;
}
}
// Eight original passengers, two original teams, one consumed load per aircraft.
class ERR_AirAssaultLoad
{
static const ResourceName PARACHUTE = "{C31F210ABE0781A0}Prefabs/ERR/AssaultParachute.et";
protected ERR_GameMode m_Mode;
protected ERR_ReconFlightComponent m_Flight;
protected Vehicle m_Aircraft;
protected int m_Serial, m_NextPassenger;
protected ref array<BaseCompartmentSlot> m_Seats = {};
protected ref array<ref ERR_AirPassenger> m_Passengers = {};
protected ref array<ref ERR_AirAssaultTeam> m_Teams = {};
protected ref ERR_AirDropOrder m_Order;
protected float m_CoverUntil;
bool Cover(float now, out vector center) { if (!m_Order || !m_Used || m_Approaching || now >= m_CoverUntil) return false;
center = m_Order.Target;
return true;
}
bool NeedsService(float now) { return m_Used && !m_Approaching && now >= m_CoverUntil;
}
protected float m_LoadDeadline, m_NextBoard, m_NextJump, m_AirspaceUntil;
protected bool m_Ready, m_Failed, m_Stopped, m_Used, m_Approaching, m_RunCommitted;
protected vector m_Center, m_Direction, m_RunStart, m_RunEnd;
static void SetLock(IEntity actor, bool locked)
{
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.AirAssault()) hq.AirAssault().Lock(actor, locked);
}
void ReuseRemaining()
{
m_Used = false;
m_Order = null;
m_CoverUntil = 0;
foreach (ERR_AirPassenger member : m_Passengers)
if (member.Actor && CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft) { member.Jumped = false;
member.Locked = true;
}
}
int Count() { return m_Passengers.Count();
}
ERR_AirPassenger At(int index) { return m_Passengers[index];
}
bool Used() { return m_Used;
}
bool Preparing() { return !m_Ready && !m_Failed && !m_Stopped;
}
bool Ready()
{
return m_Ready && !m_Failed && !m_Stopped && !m_Used && m_Flight && !m_Flight.Lost()
&& (m_Flight.Phase() == ERR_ReconPhase.SEARCH || m_Flight.Phase() == ERR_ReconPhase.SHADOW) && Aboard() > 0;
}
void ERR_AirAssaultLoad(ERR_GameMode mode, ERR_ReconFlightComponent flight, int serial)
{ m_Mode = mode;
m_Flight = flight;
m_Aircraft = flight.Aircraft();
m_Serial = serial;
}
protected void Seats(IEntity entity)
{
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(entity.FindComponent(BaseCompartmentManagerComponent));
if (manager)
{
array<BaseCompartmentSlot> seats = {};
manager.GetCompartments(seats);
foreach (BaseCompartmentSlot seat : seats)
if (seat && seat.GetType() == ECompartmentType.CARGO && seat.IsCompartmentAccessible() && !seat.IsOccupied() && !m_Seats.Contains(seat)) m_Seats.Insert(seat);
}
IEntity child = entity.GetChildren();
while (child) { Seats(child);
child = child.GetSibling();
}
}
protected void Failure(string reason)
{
m_Failed = true;
m_Approaching = false;
foreach (ERR_AirPassenger member : m_Passengers) member.Locked = false;
ERR_HQ.Event("AIR LOAD UNAVAILABLE", reason + "; original partial entities retained, reconnaissance remains available");
}
void Load(vector pad)
{
m_LoadDeadline = GetGame().GetWorld().GetWorldTime() + 30000;
Seats(m_Aircraft);
ResourceName soldier = m_Mode.FactionSelection().Soldier();
Resource chute = Resource.Load(PARACHUTE);
if (m_Seats.Count() < 8 || soldier.IsEmpty() || !chute || !chute.IsValid()) { Failure("eight usable cargo seats, soldier or canopy unavailable");
return;
}
for (int t = 0; t < 2; t++)
{
ERR_AirAssaultTeam team = new ERR_AirAssaultTeam();
team.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{C31F210ABE078195}Prefabs/ERR/ReconCrew.et", pad));
m_Teams.Insert(team);
if (!team.Group) { Failure("passenger group spawn failed");
return;
}
m_Mode.Own(team.Group);
team.Group.SetFaction(m_Mode.EnemyFaction());
team.Group.SetPermanentLOD(0);
}
for (int i = 0; i < 8; i++)
{
ERR_AirPassenger member = new ERR_AirPassenger();
member.Seat = m_Seats[i];
AIGroup group = m_Teams[i % 2].Group;
member.Actor = member.Seat.SpawnCharacterInCompartment(soldier, group, "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et");
m_Passengers.Insert(member);
m_Teams[i % 2].Members.Insert(member);
if (!member.Actor || group != m_Teams[i % 2].Group) { Failure("native passenger creation or group identity failed");
return;
}
}
}
protected void Boarding(float now)
{
foreach (ERR_AirAssaultTeam loadingTeam : m_Teams)
{
if (!loadingTeam.Group) continue;
loadingTeam.Group.ActivateAI();
array<AIAgent> loadingAgents = {};
loadingTeam.Group.GetAgents(loadingAgents);
foreach (AIAgent loadingAgent : loadingAgents) { loadingAgent.SetPermanentLOD(0);
loadingAgent.ActivateAI();
}
}
if (now >= m_LoadDeadline) { Failure(string.Format("native passenger boarding timed out; seated=%1/%2", Aboard(), Count()));
return;
}
bool all = m_Passengers.Count() == 8;
foreach (ERR_AirPassenger member : m_Passengers)
{
if (!ERR_Actors.CanFight(member.Actor)) { all = false;
continue;
}
if (CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft && member.Seat.GetOccupant() == member.Actor) continue;
all = false;
if (now < m_NextBoard || CompartmentAccessComponent.GetVehicleIn(member.Actor) || member.Seat.IsOccupied()) continue;
SCR_CompartmentAccessComponent access = SCR_CompartmentAccessComponent.Cast(member.Actor.FindComponent(SCR_CompartmentAccessComponent));
if (access) access.MoveInVehicle(m_Aircraft, ECompartmentType.CARGO, false, member.Seat);
}
if (now >= m_NextBoard) m_NextBoard = now + 1000;
if (!all) return;
for (int t = 0; t < m_Teams.Count(); t++)
{
ERR_AirAssaultTeam team = m_Teams[t];
foreach (ERR_AirPassenger original : team.Members)
if (ERR_Actors.Group(original.Actor) != team.Group || !ERR_Actors.SetFaction(original.Actor, m_Mode.EnemyFaction())) { Failure("original passenger faction/group changed");
return;
}
ERR_ForceRecord record = m_Mode.Forces().Register(string.Format("airborne-%1-team-%2", m_Serial, t), ERR_ForceKind.PATROL, m_Aircraft.GetOrigin(), 4, null, team.Group, now);
if (!record) { Failure("finite passenger ledger refused registration");
return;
}
record.Pinned = true;
m_Mode.RegisterGroup(team.Group);
ERR_HQ.Get().ReserveGroup(team.Group);
team.Group.ActivateAI();
array<AIAgent> agents = {};
team.Group.GetAgents(agents);
foreach (AIAgent agent : agents) { agent.SetPermanentLOD(0);
agent.ActivateAI();
}
}
m_Ready = true;
ERR_HQ.Event("AIR LOAD READY", "eight original passengers seated in two four-person teams");
}
int Aboard()
{
if (!m_Aircraft) return 0;
int count;
foreach (ERR_AirPassenger member : m_Passengers)
if (ERR_Actors.CanFight(member.Actor) && !ERR_Actors.Player(member.Actor) && CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft) count++;
return count;
}
bool HoldsSeat(IEntity actor)
{
if (m_Stopped || m_Failed || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor)) return false;
foreach (ERR_AirAssaultTeam team : m_Teams)
foreach (ERR_AirPassenger member : team.Members)
if (member.Actor == actor && member.Locked && ERR_Actors.Group(actor) == team.Group)
return (m_Flight && m_Flight.Phase() < ERR_ReconPhase.PARKED && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft) || (member.Chute && CompartmentAccessComponent.GetVehicleIn(actor) == member.Chute);
return false;
}
void Lock(IEntity actor, bool locked)
{ foreach (ERR_AirPassenger member : m_Passengers) if (member.Actor == actor) member.Locked = locked;
}
bool Begin(ERR_AirDropOrder order, float now)
{
if (!Ready() || !order || now >= order.ExpiresAt) return false;
vector radial = m_Aircraft.GetOrigin() - order.Target;
radial[1] = 0;
float heading = Math.Atan2(radial[0], radial[2]);
bool found;
int surveyed;
map<string, int> rejected = new map<string, int>();
// Several distances and both runway directions provide alternatives around
// woodland while retaining every landing/descent safety check.
for (int attempt = 0; attempt < 72; attempt++)
{
int bearing = attempt % 12;
int ring = Math.Floor(attempt / 24.0);
float radius = 300 + ring * 200;
float angle = heading + bearing * Math.PI / 6;
radial = Vector(Math.Sin(angle), 0, Math.Cos(angle));
m_Center = ERR_Actors.Ground(order.Target + radial * radius);
m_Direction = Vector(-radial[2], 0, radial[0]);
if ((attempt % 24) >= 12) m_Direction = radial;
surveyed++;
if (ERR_AirDropGeometry.LandingLine(m_Center, m_Direction)) { found = true; break; }
int count; rejected.Find(ERR_AirDropGeometry.LastFailure, count);
rejected.Set(ERR_AirDropGeometry.LastFailure, count + 1);
}
string summary;
foreach (string reason, int failures : rejected) summary += reason + "=" + failures.ToString() + "; ";
ERR_HQ.Event("AIR DROP SURVEY", string.Format("target=%1 checked=%2 accepted=%3 center=%4; %5", order.Target, surveyed, found, m_Center, summary));
if (!found) return false;
m_RunStart = m_Center - m_Direction * 350;
m_RunEnd = m_Center + m_Direction * 450;
m_CoverUntil = now + 300000;
m_Order = order;
m_Used = true;
m_Approaching = true;
m_RunCommitted = false;
m_NextPassenger = 0;
m_NextJump = now;
foreach (ERR_AirAssaultTeam team : m_Teams) team.Order = order;
m_AirspaceUntil = order.ExpiresAt + 150000;
return true;
}
bool Approach(out vector point)
{
point = m_RunStart;
if (m_RunCommitted) point = m_RunEnd;
return m_Approaching && !m_Stopped;
}
void Abort()
{ if (m_Approaching && m_Order) m_CoverUntil = GetGame().GetWorld().GetWorldTime() + 300000;
m_Approaching = false;
m_RunCommitted = false;
}
bool BlocksMortar(vector impact, float now)
{
if (!m_Order || vector.DistanceXZ(impact, m_Order.Target) >= 1200) return false;
foreach (ERR_AirPassenger member : m_Passengers)
if (member.Chute && member.Actor && CompartmentAccessComponent.GetVehicleIn(member.Actor) == member.Chute) return true;
return now < m_AirspaceUntil;
}
void Update(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active()) return;
foreach (ERR_AirAssaultTeam team : m_Teams) team.Update();
if (Preparing()) { Boarding(now);
return;
}
if (!m_Approaching) return;
if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_Order.ExpiresAt)
{ Abort();
ERR_HQ.Event("AIR DROP ABORTED", "authorization expired or aircraft unavailable; no new passengers created");
return;
}
foreach (ERR_AirAssaultTeam originalTeam : m_Teams)
if (!originalTeam.Group || originalTeam.Group.GetPlayerCount(true) > 0 || originalTeam.Group.GetFaction() != m_Mode.EnemyFaction()) { Abort();
return;
}
vector position = m_Aircraft.GetOrigin();
if (!m_RunCommitted && vector.DistanceXZ(position, m_RunStart) <= 80) m_RunCommitted = true;
if (m_RunCommitted && ERR_AirDropGeometry.Along(position, m_Center, m_Direction) > 300) { Abort();
return;
}
float height = position[1] - SCR_TerrainHelper.GetTerrainY(position);
if (!m_RunCommitted || now < m_NextJump || height < 90 || height > 180 || !m_Aircraft.GetPhysics()
|| !ERR_AirDropGeometry.Corridor(position, m_Center, m_Direction, m_Aircraft.GetPhysics().GetVelocity())) return;
while (m_NextPassenger < m_Passengers.Count())
{
int index = m_NextPassenger++;
ERR_AirPassenger passenger = m_Passengers[index];
if (passenger.Jumped || !ERR_Actors.CanFight(passenger.Actor) || ERR_Actors.Player(passenger.Actor) || CompartmentAccessComponent.GetVehicleIn(passenger.Actor) != m_Aircraft) continue;
vector exit = m_Aircraft.CoordToParent("12 -4 0");
ERR_AssaultParachute chute = ERR_AssaultParachute.Cast(ERR_Actors.Spawn(PARACHUTE, exit));
m_NextJump = now + 2000;
if (!chute) { ERR_HQ.Event("AIR DROP FAILED", "canopy creation refused; original passenger retained");
continue;
}
m_Mode.Own(chute);
passenger.Chute = chute;
vector landing = m_Center + m_Direction * ERR_AirDropGeometry.Along(position, m_Center, m_Direction);
passenger.Jumped = chute.BeginJump(passenger.Actor, m_Teams[index % 2], landing);
ERR_HQ.Event("AIR DROP JUMP", string.Format("original passenger=%1 transfer requested=%2", index, passenger.Jumped));
break;
}
if (m_NextPassenger >= m_Passengers.Count()) Abort();
}
void Stop()
{
m_Stopped = true;
Abort();
foreach (ERR_AirPassenger passenger : m_Passengers) passenger.Locked = false;
}
}
@@ -0,0 +1,85 @@
// Decisions consume detached HQ memory and delivered friendly check-ins only.
class ERR_AirSupportSituation
{
int BeliefId;
int Samples;
int Spotted;
int GroundFit;
int CommittedFit;
int ConfirmedLosses;
float Age;
float Confidence;
float Radius;
float Speed;
bool MotionKnown;
bool Infantry;
bool AircraftReady;
bool ArtilleryBusy;
}
class ERR_AirSupportPolicy
{
int Candidate;
float ConsideringSince = -1;
float NextDrop;
string Reason = "waiting for delivered intelligence";
static string Refusal(ERR_AirSupportSituation s, bool checkAirspace = true)
{
if (!s || !s.AircraftReady) return "no ready helicopter passenger load";
if (!ERR_HQMemoryRules.Finite(s.Age) || !ERR_HQMemoryRules.Finite(s.Confidence) || !ERR_HQMemoryRules.Finite(s.Radius) || !ERR_HQMemoryRules.Finite(s.Speed)) return "invalid memory sample";
if (s.BeliefId <= 0 || s.Samples < 2 || s.Age < 0 || s.Age > 90 || s.Confidence < 0.7 || s.Confidence > 1 || s.Radius < 0 || s.Radius > 160) return "memory is too uncertain; continue reconnaissance";
if (!s.Infantry || !s.MotionKnown || s.Speed < 0 || s.Speed > 6) return "target movement does not justify an infantry drop";
int needed = Math.Clamp(s.Spotted * 2, 4, 12);
if (s.CommittedFit >= needed) return "ground forces already committed in sufficient strength";
if (s.GroundFit + s.CommittedFit >= needed && s.ConfirmedLosses < 2) return "nearby ground forces can handle this contact";
if (checkAirspace && s.ArtilleryBusy) return "mortar order or shells occupy the approach area";
return "";
}
bool Consider(ERR_AirSupportSituation s, float now)
{
if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false;
// Artillery temporarily closes the approach; it does not invalidate fresh
// contact history or the confirmed lack of ground support. Restarting the
// timer on every salvo could prevent a drop through every clear interval.
string refusal = Refusal(s, false);
if (!refusal.IsEmpty()) { Candidate = 0;
ConsideringSince = -1;
Reason = refusal;
return false;
}
if (now < NextDrop) { Reason = "reinforcement cooldown; retain remaining troops";
return false;
}
if (Candidate != s.BeliefId || ConsideringSince < 0) { Candidate = s.BeliefId;
ConsideringSince = now;
}
if (s.ArtilleryBusy) { Reason = Refusal(s);
return false;
}
float delay = 45000;
if (s.ConfirmedLosses >= 2) delay = 20000;
if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall";
return false;
}
Reason = "persistent infantry contact exceeds available ground response";
return true;
}
void Committed(float now) { NextDrop = now + 480000;
Candidate = 0;
ConsideringSince = -1;
}
void Retry(float now) { NextDrop = Math.Max(NextDrop, now + 15000);
Candidate = 0;
ConsideringSince = -1;
}
}
// One immutable authorization; receiver revalidates memory and physical safety.
class ERR_AirDropOrder
{
int BeliefId;
int BeliefRevision;
int MessageId;
vector Target;
float ExpiresAt;
ref ERR_HQReport Report;
}
@@ -0,0 +1,77 @@
"""Execute production HQ reinforcement policy against confidence and force scenarios."""
from policy_harness import ROOT, run
import re
source=(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c').read_text().split('// One immutable')[0]
source=source.replace('class ', 'struct ').replace('\n}', '\n};').replace('ERR_AirSupportSituation s','ERR_AirSupportSituation* s').replace('s.', 's->').replace('ERR_HQMemoryRules.','ERR_HQMemoryRules::').replace('Math.','Math::')
source=source.replace('ERR_HQMemoryRules->', 'ERR_HQMemoryRules::')
source=re.sub(r'\b(bool|int|float) (\w+);',r'\1 \2{};',source)
run(r'''
#include <cassert>
#include <string>
#include <cmath>
#include <algorithm>
#include <limits>
struct string:std::string{using std::string::string;bool IsEmpty(){return empty();}};
struct Math {static int Clamp(int x,int a,int b){return std::clamp(x,a,b);}static float Max(float a,float b){return std::max(a,b);}};
struct ERR_HQMemoryRules{static bool Finite(float v){return std::isfinite(v);}};
'''+source+r'''
int main(){
ERR_AirSupportSituation s{};s.AircraftReady=true;s.BeliefId=1;s.Samples=3;s.Spotted=3;s.Age=5;s.Confidence=.9;s.Radius=20;s.MotionKnown=true;s.Infantry=true;s.Speed=1;
ERR_AirSupportPolicy p;assert(!p.Consider(&s,0));assert(!p.Consider(&s,44999));assert(p.Consider(&s,45000));
p.Committed(45000);assert(!p.Consider(&s,50000));assert(!p.Consider(&s,525000));assert(p.Consider(&s,570000));
s.CommittedFit=6;assert(!p.Consider(&s,600000));s.CommittedFit=0;s.GroundFit=6;assert(!p.Consider(&s,600000));
s.ConfirmedLosses=2;assert(!p.Consider(&s,600000));assert(p.Consider(&s,620000));
s.ArtilleryBusy=true;assert(!p.Consider(&s,630000));s.ArtilleryBusy=false;assert(p.Consider(&s,640000));assert(p.Consider(&s,660000));
s.BeliefId=2;assert(!p.Consider(&s,670000));assert(!p.Consider(&s,689999));assert(p.Consider(&s,690000));
auto good=s;
s.Age=91;assert(!p.Consider(&s,700000));s=good;s.Confidence=.69;assert(!p.Consider(&s,700000));
s=good;s.Radius=161;assert(!p.Consider(&s,700000));s=good;s.Samples=1;assert(!p.Consider(&s,700000));
s=good;s.Infantry=false;assert(!p.Consider(&s,700000));s=good;s.Speed=7;assert(!p.Consider(&s,700000));
s=good;s.MotionKnown=false;assert(!p.Consider(&s,700000));s=good;s.AircraftReady=false;assert(!p.Consider(&s,700000));
s=good;s.Age=std::numeric_limits<float>::quiet_NaN();assert(!p.Consider(&s,700000));
assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits<float>::infinity()));
// Artillery blocks authorization, not the history of fresh sightings and
// ground-force shortage. Repeated <45-second clear windows used to starve
// airborne support by restarting its confirmation timer on every salvo.
ERR_AirSupportPolicy blocked;auto contact=good;contact.BeliefId=9;contact.GroundFit=0;contact.ConfirmedLosses=0;
assert(!blocked.Consider(&contact,0));
contact.ArtilleryBusy=true;
assert(!blocked.Consider(&contact,10000));
assert(!blocked.Consider(&contact,46000));
contact.ArtilleryBusy=false;assert(blocked.Consider(&contact,47000));
// Stale intelligence and a changed target still invalidate confirmation.
contact.Age=91;assert(!blocked.Consider(&contact,48000));
contact.Age=5;assert(!blocked.Consider(&contact,49000));
contact.BeliefId=10;assert(!blocked.Consider(&contact,90000));
assert(!blocked.Consider(&contact,134999));assert(blocked.Consider(&contact,135000));
// Busy airspace must not preconfirm a new drop during the 8-minute cooldown.
blocked.Committed(135000);contact.ArtilleryBusy=true;
assert(!blocked.Consider(&contact,614999));
contact.ArtilleryBusy=false;assert(!blocked.Consider(&contact,615000));
assert(!blocked.Consider(&contact,659999));assert(blocked.Consider(&contact,660000));
}
''')
print('PASS: production airborne memory confidence, persistence, ground coverage, confirmed losses, artillery veto, cooldown and invalid-input rules')
from policy_harness import body
load=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c'
cover=body(load,'bool Cover(').replace('out vector center','int& center').replace('m_Order.Target','m_Order->Target')
service=body(load,'bool NeedsService(')
run(r'''
#include <cassert>
struct Order {int Target=730;};
struct Load {
Order* m_Order=nullptr;bool m_Used=false,m_Approaching=false;float m_CoverUntil=300000;
'''+cover+service+r'''
};
int main(){
Load load;Order order;int center=-1;
assert(!load.Cover(0,center)&&center==-1&&!load.NeedsService(0));
load.m_Order=&order;load.m_Used=true;load.m_Approaching=true;
assert(!load.Cover(1,center)&&!load.NeedsService(300001));
load.m_Approaching=false;assert(load.Cover(299999,center)&&center==730&&!load.NeedsService(299999));
center=-1;assert(!load.Cover(300000,center)&&center==-1&&load.NeedsService(300000));
}
''')
print('PASS: production cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished')
@@ -0,0 +1,257 @@
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c
@@ -28,9 +28,6 @@
if (!ERR_HQMemoryRules.Finite(s.Age) || !ERR_HQMemoryRules.Finite(s.Confidence) || !ERR_HQMemoryRules.Finite(s.Radius) || !ERR_HQMemoryRules.Finite(s.Speed)) return "invalid memory sample";
if (s.BeliefId <= 0 || s.Samples < 2 || s.Age < 0 || s.Age > 90 || s.Confidence < 0.7 || s.Confidence > 1 || s.Radius < 0 || s.Radius > 160) return "memory is too uncertain; continue reconnaissance";
if (!s.Infantry || !s.MotionKnown || s.Speed < 0 || s.Speed > 6) return "target movement does not justify an infantry drop";
- int needed = Math.Clamp(s.Spotted * 2, 4, 12);
- if (s.CommittedFit >= needed) return "ground forces already committed in sufficient strength";
- if (s.GroundFit + s.CommittedFit >= needed && s.ConfirmedLosses < 2) return "nearby ground forces can handle this contact";
if (checkAirspace && s.ArtilleryBusy) return "mortar order or shells occupy the approach area";
return "";
}
@@ -38,7 +35,7 @@
{
if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false;
// Artillery temporarily closes the approach; it does not invalidate fresh
- // contact history or the confirmed lack of ground support. Restarting the
+ // contact history. Restarting the
// timer on every salvo could prevent a drop through every clear interval.
string refusal = Refusal(s, false);
if (!refusal.IsEmpty()) { Candidate = 0;
@@ -55,15 +52,18 @@
if (s.ArtilleryBusy) { Reason = Refusal(s);
return false;
}
- float delay = 45000;
- if (s.ConfirmedLosses >= 2) delay = 20000;
- if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall";
+ // Ground hunters complement airborne troops rather than vetoing all drops.
+ int needed = Math.Clamp(s.Spotted * 2, 4, 12);
+ float delay = 20000;
+ if (s.GroundFit + s.CommittedFit >= needed) delay = 45000;
+ if (s.ConfirmedLosses >= 2) delay = 10000;
+ if (now - ConsideringSince < delay) { Reason = "confirming contact before airborne reinforcement";
return false;
}
- Reason = "persistent infantry contact exceeds available ground response";
+ Reason = "persistent infantry contact; reinforcing the ground hunt";
return true;
}
- void Committed(float now) { NextDrop = now + 480000;
+ void Committed(float now) { NextDrop = now + 240000;
Candidate = 0;
ConsideringSince = -1;
}
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c
@@ -33,6 +33,12 @@
class ERR_AirDropGeometry
{
static string LastFailure;
+ // Once a fresh radio order is accepted, give the physical approach a bounded
+ // travel budget. A large island may require several minutes of flying.
+ static float ApproachBudget(float distance, float descent)
+ {
+ return Math.Clamp(Math.Max(distance / 10, Math.Max(0, descent) / 2) + 90, 120, 600) * 1000;
+ }
static float Along(vector position, vector center, vector direction)
{ vector offset = position - center;
offset[1] = 0;
@@ -77,7 +83,8 @@
protected ref array<ref ERR_AirPassenger> m_Passengers = {};
protected ref array<ref ERR_AirAssaultTeam> m_Teams = {};
protected ref ERR_AirDropOrder m_Order;
- protected float m_CoverUntil;
+ protected float m_CoverUntil, m_ApproachUntil;
+ protected bool m_HasDropped;
bool Cover(float now, out vector center) { if (!m_Order || !m_Used || m_Approaching || now >= m_CoverUntil) return false;
center = m_Order.Target;
return true;
@@ -95,6 +102,7 @@
void ReuseRemaining()
{
m_Used = false;
+ m_HasDropped = false;
m_Order = null;
m_CoverUntil = 0;
foreach (ERR_AirPassenger member : m_Passengers)
@@ -282,7 +290,11 @@
if (!found) return false;
m_RunStart = m_Center - m_Direction * 350;
m_RunEnd = m_Center + m_Direction * 450;
- m_CoverUntil = now + 300000;
+ m_CoverUntil = 0;
+ m_HasDropped = false;
+ vector aircraftPosition = m_Aircraft.GetOrigin();
+ m_ApproachUntil = now + ERR_AirDropGeometry.ApproachBudget(vector.DistanceXZ(aircraftPosition, m_RunStart), aircraftPosition[1] - m_Center[1] - 120);
+ ERR_HQ.Event("AIR DROP APPROACH", string.Format("distance=%1m budget=%2s; original passengers retained", vector.DistanceXZ(aircraftPosition, m_RunStart), (m_ApproachUntil - now) / 1000));
m_Order = order;
m_Used = true;
m_Approaching = true;
@@ -290,7 +302,7 @@
m_NextPassenger = 0;
m_NextJump = now;
foreach (ERR_AirAssaultTeam team : m_Teams) team.Order = order;
- m_AirspaceUntil = order.ExpiresAt + 150000;
+ m_AirspaceUntil = m_ApproachUntil + 150000;
return true;
}
bool Approach(out vector point)
@@ -300,9 +312,15 @@
return m_Approaching && !m_Stopped;
}
void Abort()
- { if (m_Approaching && m_Order) m_CoverUntil = GetGame().GetWorld().GetWorldTime() + 300000;
+ {
m_Approaching = false;
m_RunCommitted = false;
+ // An approach with no jump is not a completed drop. The same original
+ // passengers can accept a later order; no fictitious landed-team cover.
+ if (!m_HasDropped)
+ {
+ m_Used = false; m_Order = null; m_CoverUntil = 0; m_AirspaceUntil = 0;
+ }
}
bool BlocksMortar(vector impact, float now)
{
@@ -319,9 +337,9 @@
return;
}
if (!m_Approaching) return;
- if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_Order.ExpiresAt)
+ if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_ApproachUntil)
{ Abort();
- ERR_HQ.Event("AIR DROP ABORTED", "authorization expired or aircraft unavailable; no new passengers created");
+ ERR_HQ.Event("AIR DROP ABORTED", "approach deadline reached or aircraft unavailable; no new passengers created");
return;
}
foreach (ERR_AirAssaultTeam originalTeam : m_Teams)
@@ -331,6 +349,7 @@
vector position = m_Aircraft.GetOrigin();
if (!m_RunCommitted && vector.DistanceXZ(position, m_RunStart) <= 80) m_RunCommitted = true;
if (m_RunCommitted && ERR_AirDropGeometry.Along(position, m_Center, m_Direction) > 300) { Abort();
+ ERR_HQ.Event("AIR DROP ABORTED", "drop run passed the safe corridor; unjumped original passengers retained");
return;
}
float height = position[1] - SCR_TerrainHelper.GetTerrainY(position);
@@ -351,6 +370,7 @@
passenger.Chute = chute;
vector landing = m_Center + m_Direction * ERR_AirDropGeometry.Along(position, m_Center, m_Direction);
passenger.Jumped = chute.BeginJump(passenger.Actor, m_Teams[index % 2], landing);
+ if (passenger.Jumped) { m_HasDropped = true; m_CoverUntil = now + 300000; }
ERR_HQ.Event("AIR DROP JUMP", string.Format("original passenger=%1 transfer requested=%2", index, passenger.Jumped));
break;
}
--- a/tests/test_air_support_policy.py
+++ b/tests/test_air_support_policy.py
@@ -17,12 +17,16 @@
'''+source+r'''
int main(){
ERR_AirSupportSituation s{};s.AircraftReady=true;s.BeliefId=1;s.Samples=3;s.Spotted=3;s.Age=5;s.Confidence=.9;s.Radius=20;s.MotionKnown=true;s.Infantry=true;s.Speed=1;
- ERR_AirSupportPolicy p;assert(!p.Consider(&s,0));assert(!p.Consider(&s,44999));assert(p.Consider(&s,45000));
- p.Committed(45000);assert(!p.Consider(&s,50000));assert(!p.Consider(&s,525000));assert(p.Consider(&s,570000));
- s.CommittedFit=6;assert(!p.Consider(&s,600000));s.CommittedFit=0;s.GroundFit=6;assert(!p.Consider(&s,600000));
- s.ConfirmedLosses=2;assert(!p.Consider(&s,600000));assert(p.Consider(&s,620000));
- s.ArtilleryBusy=true;assert(!p.Consider(&s,630000));s.ArtilleryBusy=false;assert(p.Consider(&s,640000));assert(p.Consider(&s,660000));
- s.BeliefId=2;assert(!p.Consider(&s,670000));assert(!p.Consider(&s,689999));assert(p.Consider(&s,690000));
+ ERR_AirSupportPolicy p;assert(!p.Consider(&s,0));assert(!p.Consider(&s,19999));assert(p.Consider(&s,20000));
+ p.Committed(20000);assert(!p.Consider(&s,259999));assert(!p.Consider(&s,260000));assert(p.Consider(&s,280000));
+ // Existing hunters delay reinforcement, but no longer suppress it forever.
+ ERR_AirSupportPolicy ground;s.CommittedFit=6;
+ assert(!ground.Consider(&s,0));assert(!ground.Consider(&s,44999));assert(ground.Consider(&s,45000));
+ s.CommittedFit=0;s.GroundFit=6;assert(ground.Consider(&s,45000));
+ ERR_AirSupportPolicy losses;s.ConfirmedLosses=2;
+ assert(!losses.Consider(&s,0));assert(!losses.Consider(&s,9999));assert(losses.Consider(&s,10000));
+ s.ArtilleryBusy=true;assert(!losses.Consider(&s,20000));s.ArtilleryBusy=false;assert(losses.Consider(&s,20001));
+ s.BeliefId=2;assert(!losses.Consider(&s,30000));assert(!losses.Consider(&s,39999));assert(losses.Consider(&s,40000));
auto good=s;
s.Age=91;assert(!p.Consider(&s,700000));s=good;s.Confidence=.69;assert(!p.Consider(&s,700000));
s=good;s.Radius=161;assert(!p.Consider(&s,700000));s=good;s.Samples=1;assert(!p.Consider(&s,700000));
@@ -30,25 +34,22 @@
s=good;s.MotionKnown=false;assert(!p.Consider(&s,700000));s=good;s.AircraftReady=false;assert(!p.Consider(&s,700000));
s=good;s.Age=std::numeric_limits<float>::quiet_NaN();assert(!p.Consider(&s,700000));
assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits<float>::infinity()));
- // Artillery blocks authorization, not the history of fresh sightings and
- // ground-force shortage. Repeated <45-second clear windows used to starve
- // airborne support by restarting its confirmation timer on every salvo.
+ // Busy mortar airspace still vetoes drops without discarding contact history.
ERR_AirSupportPolicy blocked;auto contact=good;contact.BeliefId=9;contact.GroundFit=0;contact.ConfirmedLosses=0;
- assert(!blocked.Consider(&contact,0));
- contact.ArtilleryBusy=true;
- assert(!blocked.Consider(&contact,10000));
- assert(!blocked.Consider(&contact,46000));
- contact.ArtilleryBusy=false;assert(blocked.Consider(&contact,47000));
- // Stale intelligence and a changed target still invalidate confirmation.
- contact.Age=91;assert(!blocked.Consider(&contact,48000));
- contact.Age=5;assert(!blocked.Consider(&contact,49000));
- contact.BeliefId=10;assert(!blocked.Consider(&contact,90000));
- assert(!blocked.Consider(&contact,134999));assert(blocked.Consider(&contact,135000));
- // Busy airspace must not preconfirm a new drop during the 8-minute cooldown.
- blocked.Committed(135000);contact.ArtilleryBusy=true;
- assert(!blocked.Consider(&contact,614999));
- contact.ArtilleryBusy=false;assert(!blocked.Consider(&contact,615000));
- assert(!blocked.Consider(&contact,659999));assert(blocked.Consider(&contact,660000));
+ assert(!blocked.Consider(&contact,0));contact.ArtilleryBusy=true;
+ assert(!blocked.Consider(&contact,10000));assert(!blocked.Consider(&contact,21000));
+ contact.ArtilleryBusy=false;assert(blocked.Consider(&contact,22000));
+ contact.Age=91;assert(!blocked.Consider(&contact,23000));
+ contact.Age=5;assert(!blocked.Consider(&contact,24000));
+ contact.BeliefId=10;assert(!blocked.Consider(&contact,45000));
+ assert(!blocked.Consider(&contact,64999));assert(blocked.Consider(&contact,65000));
+ // Cooldown cannot preconfirm the next drop, and failed orders back off.
+ blocked.Committed(65000);contact.ArtilleryBusy=true;
+ assert(!blocked.Consider(&contact,304999));
+ contact.ArtilleryBusy=false;assert(!blocked.Consider(&contact,305000));
+ assert(!blocked.Consider(&contact,324999));assert(blocked.Consider(&contact,325000));
+ blocked.Retry(325000);assert(!blocked.Consider(&contact,339999));
+ assert(!blocked.Consider(&contact,340000));assert(blocked.Consider(&contact,360000));
}
''')
@@ -58,12 +59,17 @@
load=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c'
cover=body(load,'bool Cover(').replace('out vector center','int& center').replace('m_Order.Target','m_Order->Target')
service=body(load,'bool NeedsService(')
+abort=body(load,'void Abort(').replace('null','nullptr')
+budget=body(load,'static float ApproachBudget(').replace('Math.', 'Math::')
run(r'''
#include <cassert>
+#include <algorithm>
+struct Math {static float Clamp(float x,float a,float b){return std::clamp(x,a,b);}static float Max(float a,float b){return std::max(a,b);}};
+struct Geometry {'''+budget+r'''};
struct Order {int Target=730;};
struct Load {
- Order* m_Order=nullptr;bool m_Used=false,m_Approaching=false;float m_CoverUntil=300000;
-'''+cover+service+r'''
+ Order* m_Order=nullptr;bool m_Used=false,m_Approaching=false,m_RunCommitted=false,m_HasDropped=false;float m_CoverUntil=300000,m_AirspaceUntil=450000;
+'''+cover+service+abort+r'''
};
int main(){
Load load;Order order;int center=-1;
@@ -72,6 +78,24 @@
assert(!load.Cover(1,center)&&!load.NeedsService(300001));
load.m_Approaching=false;assert(load.Cover(299999,center)&&center==730&&!load.NeedsService(299999));
center=-1;assert(!load.Cover(300000,center)&&center==-1&&load.NeedsService(300000));
+ // Previously an expired approach with nobody dropped falsely entered cover
+ // and made the original passenger load unavailable until an airport visit.
+ load.m_Approaching=true;load.m_RunCommitted=true;load.Abort();
+ assert(!load.m_Order&&!load.m_Used&&!load.m_Approaching&&!load.m_RunCommitted);
+ assert(!load.Cover(1,center)&&!load.NeedsService(1)&&load.m_AirspaceUntil==0);
+ load.Abort();assert(!load.m_Used); // idempotent, no new soldiers
+ // A partial/complete real drop must retain cover and descending airspace.
+ load.m_Order=&order;load.m_HasDropped=true;load.m_Used=true;load.m_Approaching=true;
+ load.m_CoverUntil=350000;load.m_AirspaceUntil=450000;load.Abort();
+ assert(load.Cover(349999,center)&&load.m_Used&&load.m_Order==&order&&load.m_AirspaceUntil==450000);
+ assert(load.NeedsService(350000));
+ // Separate radio freshness (unchanged) from physical travel time. Observed
+ // approaches of ~1.1 km and ~5 km cannot complete within the old 120 seconds.
+ assert(Geometry::ApproachBudget(0,0)==120000);
+ assert(Geometry::ApproachBudget(1100,0)==200000);
+ assert(Geometry::ApproachBudget(5000,0)==590000);
+ assert(Geometry::ApproachBudget(0,600)==390000);
+ assert(Geometry::ApproachBudget(20000,2000)==600000);
}
''')
-print('PASS: production cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished')
+print('PASS: production bounded approach deadline, empty-drop reuse, partial-drop protection; cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished')
@@ -0,0 +1,47 @@
{
"reason": "User saw no parachute drops over multiple missions.",
"server_evidence": {
"log": "logs_2026-10-08_16-41-19",
"findings": [
"Eight original passengers ready in three sessions.",
"Session 1791477771-2: authorized t=1407.1, aborted t=1509.3 with no jump; plane was still approaching.",
"Session 1791479403-3: authorized t=1132.8 about 5 km from contact, aborted t=1249.9 with no jump.",
"Following second abort, helicopter logged covering landed airborne squad although no AIR DROP JUMP occurred."
]
},
"changes": [
"Ground support delays reinforcement to 45 seconds instead of hard veto; ordinary contact 20 seconds; confirmed losses 10 seconds. Intelligence quality, radio delivery and airspace checks retained.",
"Cooldown reduced from eight to four minutes.",
"After fresh order acceptance, separate bounded 2\u201310 minute approach budget uses route distance and descent; stale orders still rejected before acceptance.",
"Zero-jump abort clears cover and reopens original passenger load for a later order, without spawning troops. Partial-drop cover and airspace protection retained.",
"Approach distance/budget and missed corridor logged."
],
"validation": {
"policy_regression": "test_air_support_policy.py passed; actual policy/abort/budget method bodies adapted to C++ with native services doubled.",
"native_compile": {
"result": "compiled",
"cleanup": {
"cleanup_confirmed": true,
"idle_verified": true,
"errors": [],
"orphan_cleanup": {
"attempted": true,
"report": {
"scope": "/tmp/erri-headless-compat/pfx",
"candidates": [],
"term_sent": [],
"kill_sent": [],
"cleared": true
}
}
}
},
"live_flight": "Not tested; no live multiplayer/drop-success claim."
},
"source_sha256": {
"addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c": "c1a7ac8759ca8ec8b43cbccf303255a2f0b69de447d0d991e44b6bd3db415d51",
"addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c": "de7b6c485167ab68ab0b37b96fe161ee821c2efd41362172b810448a02110e68",
"tests/test_air_support_policy.py": "7ef7491bd76df5169bb988c5eb533d7a4a7240b69578c34779bba04aab72ec65"
},
"deployment": "Source only; no publishing or live server modifications."
}