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,625 @@
// A bounded response by original hunter crews. Enemy positions are immutable
// radio-delivered snapshots; native perception still owns aiming and firing.
class ERR_HunterAttackRules
{
static float ManeuverBudget(float metres) { return Math.Clamp(30 + metres / 2, 90, 240) * 1000; }
// Progress can extend a road approach, but never indefinitely.
static bool ExtendApproach(float started, float progress, float now)
{
return now >= started && now - started < 300000 && now >= progress && now - progress <= 15000;
}
static bool AdvanceWithoutVisualCover(bool armedCover, float waitingUntil, float now)
{
// Give the gunner 15 seconds to acquire a target, then investigate the
// delivered sector. Support availability is not a claim of suppressing fire.
return armedCover && now >= waitingUntil - 30000;
}
static bool DismountRange(float range) { return range >= 140 && range <= 450; }
static bool Fresh(float observed, float now) { return observed >= 0 && now >= observed && now - observed <= 60000; }
static vector Offset(vector contact, vector forward, int role, int phase)
{
vector left = Vector(-forward[2], 0, forward[0]);
if (role == 0) return contact - forward * 210;
float side = 1; if (role == 2) side = -1;
if (phase == 0) return contact - forward * 240 + left * (side * 155);
if (phase == 1) return contact - forward * 85 + left * (side * 170);
return contact + forward * 35 + left * (side * 105);
}
static bool Stage(vector point, vector contact, vector forward, array<vector> occupied)
{
float distance = vector.DistanceXZ(point, contact);
if (distance < 140 || distance > 500 || vector.Dot(point - contact, forward) > -90) return false;
foreach (vector other : occupied) if (vector.DistanceXZ(point, other) < 85) return false;
return true;
}
static bool Foot(vector point, vector contact, vector forward, int role)
{
vector left = Vector(-forward[2], 0, forward[0]);
float side = vector.Dot(point - contact, left);
if (role == 2) side = -side;
return side >= 65 && vector.DistanceXZ(point, contact) >= 85;
}
}
// Geometry checks only the delivered sector, never a hidden target entity.
// A clear lane is a candidate firing position, not proof of suppression.
class ERR_MountedCover
{
static bool SeesSector(SCR_AIGroup group, vector contact)
{
if (!group || !group.GetGroupUtilityComponent()) return false;
array<ref ERR_HQVisualContact> visible = {};
ERR_HQObservation.Gather(group.GetGroupUtilityComponent(), GetGame().GetWorld().GetWorldTime(), visible);
foreach (ERR_HQVisualContact sighting : visible)
if (vector.DistanceXZ(sighting.Position, contact) <= 200) return true;
return false;
}
static bool ClearLane(IEntity vehicle, vector position, vector contact)
{
TraceParam trace = new TraceParam();
trace.Start = position + "0 2 0"; trace.End = contact + "0 1 0";
trace.Exclude = vehicle; trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
return GetGame().GetWorld().TraceMove(trace, null) >= 1;
}
static vector Approach(vector origin, vector contact, vector fallback)
{
vector forward = contact - origin; forward[1] = 0;
if (forward.Length() < 1) return fallback;
forward.Normalize(); return forward;
}
}
class ERR_HunterAttack
{
int BeliefId;
vector ReportPosition;
vector Forward;
float Observed;
float Started;
int Message;
ref array<ref ERR_HunterTactics> Teams = {};
bool CoverAlive()
{
foreach (ERR_HunterTactics team : Teams)
if (team.Delivered && team.ArmedCover()) return true;
return false;
}
bool CoverReady()
{
foreach (ERR_HunterTactics team : Teams)
if (team.Covering()) return true;
return false;
}
}
class ERR_HunterTactics
{
ERR_HunterVehicle Unit;
ERR_HunterAttack Attack;
int Role; // armed covering vehicle, left infantry, right infantry
vector Stage;
vector Approach;
vector Sector;
ref ERR_HQRoute Radio;
ref array<AIWaypoint> Orders = {};
int State; // 0 radio, 1 approach, 2 dismount, 3 cover, 4 flank, 5 search, 6 return, 8 short alignment
float Deadline;
float NextRetry;
int Retries;
bool Delivered;
bool Stopped;
protected bool m_VehicleDetached;
protected float m_TravelDistance;
protected float m_NextStateLog;
protected float m_ApproachStarted;
protected float m_ApproachProgress;
protected vector m_ApproachPosition;
protected bool m_UnsupportedSearch;
protected ref ERR_HunterGunTeam m_GunTeam;
protected ERR_GameMode m_Mode;
void ERR_HunterTactics(ERR_GameMode mode, ERR_HunterVehicle unit, ERR_HunterAttack attack, int role)
{
m_Mode = mode; Unit = unit; Attack = attack; Role = role;
}
string Label()
{
if (Role == 0) return "cover";
if (Role == 1) return "left flank";
return "right flank";
}
bool Living()
{
if (Stopped || !Unit || Unit.Released || !Unit.Group || !Unit.Entity || !ERR_Actors.Alive(Unit.Entity) || Unit.Group.GetFaction() != m_Mode.EnemyFaction()) return false;
foreach (IEntity member : Unit.Members)
if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Unit.Group) return true;
return false;
}
bool Aboard(bool required)
{
foreach (IEntity member : Unit.Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != Unit.Group) continue;
if (!required && m_GunTeam && m_GunTeam.Retained(member)) continue;
if ((CompartmentAccessComponent.GetVehicleIn(member) == Unit.Entity) != required) return false;
}
return true;
}
vector Position()
{
// The native group leader can be the stationary gunner. Infantry progress
// must follow an original dismounted member rather than the parked vehicle.
if (m_GunTeam)
foreach (IEntity member : Unit.Members)
if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Unit.Group && !CompartmentAccessComponent.GetVehicleIn(member)) return member.GetOrigin();
IEntity leader = Unit.Group.GetLeaderEntity();
if (leader) return leader.GetOrigin();
return Unit.Entity.GetOrigin();
}
protected bool VisualCover()
{
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
if (!utility) return false;
// Cover is useful when this crew really sees a threat in the reported area,
// not merely when a trace to an old point happens to pass between the trees.
array<ref ERR_HQVisualContact> visible = {};
ERR_HQObservation.Gather(utility, GetGame().GetWorld().GetWorldTime(), visible);
foreach (ERR_HQVisualContact sighting : visible)
if (vector.DistanceXZ(sighting.Position, Attack.ReportPosition) <= 200) return true;
return false;
}
static bool CoverCrew(ERR_HunterVehicle unit, ERR_GameMode mode)
{
if (!unit || !unit.CrewValid(mode)) return false;
bool gunner;
foreach (BaseCompartmentSlot seat : unit.Seats)
if (seat && seat.GetType() == ECompartmentType.TURRET && unit.Members.Contains(seat.GetOccupant()) && ERR_Actors.CanFight(seat.GetOccupant())) gunner = true;
if (!gunner) return false;
ERR_HQMountedAmmoSnapshot ammo = ERR_HQMountedAmmoSampler.Inspect(unit.Entity);
return ammo && ammo.Complete && ammo.Rounds + ammo.Chambers > 0;
}
bool ArmedCover()
{
if (!Living() || !Delivered) return false;
if (m_GunTeam && State >= 3 && State <= 5) return m_GunTeam.Cover();
return Role == 0 && State == 3 && CoverCrew(Unit, m_Mode);
}
bool Covering()
{
if (!ArmedCover()) return false;
if (m_GunTeam) return m_GunTeam.Visual(Attack.ReportPosition);
return VisualCover();
}
protected void Clear()
{
foreach (AIWaypoint waypoint : Orders)
{
if (waypoint && Unit && Unit.Group) Unit.Group.RemoveWaypoint(waypoint);
if (waypoint) SCR_EntityHelper.DeleteEntityAndChildren(waypoint);
}
Orders.Clear();
}
protected void Add(AIWaypoint waypoint) { Orders.Insert(waypoint); Unit.Group.AddWaypoint(waypoint); }
protected bool Move(vector position, float radius = 20)
{
AIWaypoint move = ERR_SupplyConvoyTactics.Move(position, radius);
if (!move) return false;
AIWaypoint corner;
float travel = vector.DistanceXZ(Position(), position);
if (Role > 0 && State == 3)
{
vector origin = Position();
vector delta = position - origin; delta[1] = 0;
float lengthSq = vector.Dot(delta, delta);
float along;
if (lengthSq > 1) along = Math.Clamp(vector.Dot(Attack.ReportPosition - origin, delta) / lengthSq, 0, 1);
if (vector.DistanceXZ(origin + delta * along, Attack.ReportPosition) < 130)
{
vector left = Vector(-Attack.Forward[2], 0, Attack.Forward[0]);
float side = 1; if (Role == 2) side = -1;
vector desired = Attack.ReportPosition + Attack.Forward * vector.Dot(origin - Attack.ReportPosition, Attack.Forward) + left * (side * 230);
vector detour;
if (!FootPosition(desired, detour)) { SCR_EntityHelper.DeleteEntityAndChildren(move); return false; }
travel = vector.DistanceXZ(origin, detour) + vector.DistanceXZ(detour, position);
corner = ERR_SupplyConvoyTactics.Move(detour, 20);
if (!corner) { SCR_EntityHelper.DeleteEntityAndChildren(move); return false; }
}
}
SCR_AIWaypoint commanded = SCR_AIWaypoint.Cast(move);
if (m_GunTeam)
{
vector via; if (corner) via = corner.GetOrigin();
bool issued = m_GunTeam.Move(position, via);
SCR_EntityHelper.DeleteEntityAndChildren(move);
if (corner) SCR_EntityHelper.DeleteEntityAndChildren(corner);
if (!issued) return false;
Clear(); m_TravelDistance = travel; return true;
}
if (commanded) commanded.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER);
SCR_AIWaypoint first = SCR_AIWaypoint.Cast(corner);
if (first) first.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER);
Clear(); if (corner) Add(corner); Add(move); m_TravelDistance = travel; return true;
}
protected void VehicleUse(bool enabled)
{
if (!Unit || !Unit.Group || !Unit.Entity) return;
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(Unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!utility || !usage) return;
if (!enabled && !m_VehicleDetached) { utility.RemoveUsableVehicle(usage); m_VehicleDetached = true; }
else if (enabled && m_VehicleDetached && Living() && m_Mode.Active() && !ERR_ConvoyRules.Captured(Unit.Entity, Unit.Group))
{ utility.AddUsableVehicle(usage); m_VehicleDetached = false; }
}
protected bool Board(bool enter)
{
if (!enter)
{
if (!m_GunTeam)
{
m_GunTeam = new ERR_HunterGunTeam(Unit);
if (!m_GunTeam.Start()) m_GunTeam = null;
}
if (m_GunTeam)
{
Clear();
if (Unit.Recovery) Unit.Recovery.SuspendBoarding();
VehicleUse(false);
Note("driver and passengers dismount; original loaded gunner stays to cover");
return true;
}
}
AIWaypoint created = ERR_SupplyConvoyTactics.Board(Unit.Entity, enter);
SCR_BoardingWaypoint waypoint = SCR_BoardingWaypoint.Cast(created);
if (!waypoint)
{
if (created) SCR_EntityHelper.DeleteEntityAndChildren(created);
return false;
}
if (enter)
{
if (m_GunTeam) { m_GunTeam.Stop(); m_GunTeam = null; }
VehicleUse(true);
}
waypoint.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER);
waypoint.SetAllowance(true, true, true);
Clear(); Add(waypoint); return true;
}
protected bool FacingSector()
{
vector direction = Attack.ReportPosition - Unit.Entity.GetOrigin(); direction[1] = 0;
if (direction.Length() < 1) return true;
vector transform[4]; Unit.Entity.GetTransform(transform);
vector forward = transform[2]; forward[1] = 0;
return vector.Dot(forward.Normalized(), direction.Normalized()) >= 0.707;
}
protected bool TryAlign()
{
// Best-effort native steering, not rotation/teleporting. Never delay an
// already engaged crew or drive a vehicle into the reported enemy position.
if (FacingSector() || ERR_HunterCombat.Active(Unit.Group) || !ERR_HunterGunTeam.LoadedGunner(Unit)) return false;
vector origin = Unit.Entity.GetOrigin();
vector direction = Attack.ReportPosition - origin; direction[1] = 0;
if (direction.Length() < 180) return false;
vector point = ERR_Actors.Ground(origin + direction.Normalized() * 20);
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
if (!utility || !utility.IsPositionAllowed(point) || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) return false;
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
if (!placement.Clear(point, ERR_ForceRules.Heading(origin, point, 0))) return false;
return Move(point, 5);
}
protected void Note(string detail)
{
ERR_HQ.Event("HUNTER TACTICS", Unit.Source + " " + Label() + ": " + detail, Attack.Message);
}
protected void ObserveState(float now)
{
if (now < m_NextStateLog) return;
m_NextStateLog = now + 30000;
int aboard;
foreach (IEntity member : Unit.Members)
if (member && CompartmentAccessComponent.GetVehicleIn(member) == Unit.Entity) aboard++;
Note(string.Format("state=%1 position=%2 stage distance=%3m crew aboard=%4 armed support=%5 visual cover=%6 remaining=%7s",
State, Unit.Entity.GetOrigin(), vector.DistanceXZ(Unit.Entity.GetOrigin(), Stage), aboard,
Attack.CoverAlive(), Attack.CoverReady(), Math.Max(0, Deadline - now) / 1000));
}
bool Plan(array<vector> occupied)
{
if (!Living()) return false;
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
if (!utility) return false;
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
float currentRange = vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition);
if (Role == 0 && currentRange >= 140 && currentRange <= 500 && VisualCover())
{ Stage = Unit.Entity.GetOrigin(); return true; }
// Approach from this crew's side of the contact. A common cover axis used
// to send the opposite-side truck around the entire fight before dismount.
vector approachForward = ERR_MountedCover.Approach(Unit.Entity.GetOrigin(), Attack.ReportPosition, Attack.Forward);
vector desired = Attack.ReportPosition - approachForward * 260;
if (Role == 0) desired = Attack.ReportPosition - approachForward * 210;
vector approachLeft = Vector(-approachForward[2], 0, approachForward[0]);
bool found;
for (int attempt = 0; attempt < 8; attempt++)
{
vector point, angles;
float lateral = Math.Ceil(attempt * 0.5) * 45;
if (attempt % 2 == 0) lateral = -lateral;
vector seed = desired - approachForward * (attempt * 15) + approachLeft * lateral;
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, point, angles, 100)) continue;
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, occupied) || !utility.IsPositionAllowed(point) || !placement.Clear(point, angles[0], 2.5)) continue;
if (Role == 0 && !ERR_MountedCover.ClearLane(Unit.Entity, point, Attack.ReportPosition)) continue;
Stage = point; found = true; break;
}
// Sparse roads must not collapse every crew onto the same junction. UAZs
// may use a checked open-ground staging area; native navigation still owns travel.
for (int offroad = 0; !found && offroad < 8; offroad++)
{
vector point = desired;
if (offroad > 0) point += Vector(Math.RandomFloat(-90, 90), 0, Math.RandomFloat(-90, 90));
point = ERR_Actors.Ground(point);
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, occupied) || !utility.IsPositionAllowed(point)) continue;
if (!placement.Clear(point, ERR_ForceRules.Heading(point, Attack.ReportPosition, 0), 2.5)) continue;
if (Role == 0 && !ERR_MountedCover.ClearLane(Unit.Entity, point, Attack.ReportPosition)) continue;
Stage = point; found = true;
}
// A crew already at a safe stand-off can dismount where it is instead of
// driving across the enemy's reported position merely to fit the formation.
if (!found && Role > 0)
{
vector parked = Unit.Entity.GetOrigin();
float range = vector.DistanceXZ(parked, Attack.ReportPosition);
bool separate = true;
foreach (vector reserved : occupied) if (vector.DistanceXZ(parked, reserved) < 85) separate = false;
if (range >= 140 && range <= 500 && separate) { Stage = parked; found = true; }
}
if (!found) { Note("no separated safe approach; keeping current orders"); return false; }
if (Role == 0) return true;
bool foot = FootPosition(ERR_HunterAttackRules.Offset(Attack.ReportPosition, Attack.Forward, Role, 1), Approach)
&& FootPosition(ERR_HunterAttackRules.Offset(Attack.ReportPosition, Attack.Forward, Role, 2), Sector);
if (!foot) Note("no safe infantry flank route from approach=" + Stage.ToString() + "; keeping current orders");
return foot;
}
protected bool FootPosition(vector desired, out vector chosen)
{
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
if (!utility) return false;
for (int attempt = 0; attempt < 8; attempt++)
{
vector point = desired;
if (attempt > 0) point += Vector(Math.RandomFloat(-45, 45), 0, Math.RandomFloat(-45, 45));
point = ERR_Actors.Ground(point);
ERR_HQ coordination = ERR_HQ.Get();
if (coordination && !coordination.AttackSpace().Free(Unit.Group, point, GetGame().GetWorld().GetWorldTime())) continue;
if (!ERR_HunterAttackRules.Foot(point, Attack.ReportPosition, Attack.Forward, Role)) continue;
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !utility.IsPositionAllowed(point)) continue;
if (SCR_WorldTools.TraceCilinderUtil(point + "0 1 0", 1, 2)) { chosen = point; return true; }
}
return false;
}
bool Queue(ERR_HQ hq)
{
Radio = hq.Downlink(Unit.Group, "HUNTER TEAM", Unit.Source + " " + Label() + " stage=" + Stage.ToString() + " sector=" + Sector.ToString(), Attack.Message);
return Radio != null;
}
bool Tick(ERR_HQ hq, float now)
{
if (!Living()) { Stop(); return false; }
if (!Delivered)
{
if (!Radio) { Stop(); return false; }
int result = Radio.Advance(now, hq.Journal());
if (result == 0) return false; // Normal local reactions retain ownership until delivery.
Radio = null;
if (result < 0 || !Move(Stage, 35)) { Stop(); return false; }
Delivered = true; Unit.ClearOrder(); Unit.Downlink = null;
State = 1; Deadline = now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance);
m_ApproachStarted = now; m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin();
Note("radio delivered; separate approach=" + Stage.ToString());
if (Role == 0) foreach (IEntity crew : Unit.Members) ERR_ConvoyMountedCrew.Hold(crew, Unit.Entity);
}
ObserveState(now);
ERR_HQ coordination = ERR_HQ.Get();
if (coordination)
{
vector reserved = Stage;
if (State == 4) reserved = Approach;
if (State == 5) reserved = Sector;
coordination.AttackSpace().Claim(Unit.Group, reserved, now);
}
// Keep the original crew's driving recovery from fighting the dismount or
// returning to the UAZ fifteen seconds after the last visible target.
Unit.CombatHeld = true; Unit.CombatQuietUntil = now + 15000;
if (State == 1)
{
if (vector.DistanceXZ(Unit.Entity.GetOrigin(), m_ApproachPosition) >= 15)
{ m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin(); }
bool near = vector.DistanceXZ(Unit.Entity.GetOrigin(), Stage) <= 55;
float currentCoverRange = vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition);
bool local = ERR_HunterCombat.Active(Unit.Group) && vector.DistanceXZ(Position(), Attack.ReportPosition) <= 400;
// A road can pass close to the reported sector and then loop kilometres
// around it to reach the ideal staging point. Continue that last leg on foot.
bool onFootReach = ERR_HunterAttackRules.DismountRange(vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition));
if (Role == 0 && (near || (currentCoverRange >= 100 && currentCoverRange <= 600 && VisualCover()))) { Clear(); State = 3; Note("in covering position; native gunner owns target acquisition and fire"); }
else if (Role > 0 && (near || local || onFootReach))
{
if (TryAlign()) { State = 8; Deadline = now + 20000; Note("short native positioning move toward reported sector before dismount"); }
else if (Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; Note("within infantry approach range; dismounting assault crew"); }
else if (now >= Deadline) { Stop(); return false; }
}
else if (now >= Deadline)
{
if (ERR_HunterAttackRules.ExtendApproach(m_ApproachStarted, m_ApproachProgress, now))
{ Deadline = Math.Min(now + 30000, m_ApproachStarted + 300000); Note("approach still progressing; allowing another bounded route interval"); }
else { Note("approach made no recent progress or exhausted five-minute budget; release this assignment"); Stop(); return false; }
}
}
if (State == 8 && (FacingSector() || ERR_HunterCombat.Active(Unit.Group) || now >= Deadline))
{
if (Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; }
else if (now >= Deadline) { Stop(); return false; }
}
if (m_GunTeam && State >= 2 && State <= 5) m_GunTeam.Tick(now);
if (State == 2)
{
if (Aboard(false)) { Clear(); VehicleUse(false); State = 3; Deadline = now + 45000; Note("on foot; waiting for covering team"); }
else if (now >= Deadline) { Note("dismount incomplete; return surviving crew"); State = 6; Deadline = now + 90000; Board(true); }
else if (now >= NextRetry && Retries < 2) { NextRetry = now + 15000; Retries++; Board(false); }
}
if (State == 3 && Role > 0)
{
bool visualCover = Attack.CoverReady();
bool investigate = ERR_HunterAttackRules.AdvanceWithoutVisualCover(Attack.CoverAlive(), Deadline, now);
bool unsupported = !Attack.CoverAlive() && now >= Deadline;
if ((visualCover || investigate || unsupported) && Move(Approach, 20))
{
m_UnsupportedSearch = unsupported;
State = 4; Deadline = now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance);
if (visualCover) Note("cover established; moving around flank via " + Approach.ToString());
else if (unsupported) Note("cover lost; bounded infantry investigation of delivered sector via " + Approach.ToString());
else Note("armed support in position without visual contact; investigating reported flank via " + Approach.ToString());
}
else if (now >= Deadline)
{
Note("infantry approach could not be assigned; regrouping original survivors");
State = 6; Deadline = now + 90000; Board(true);
}
}
if (State == 4 && !m_UnsupportedSearch && !Attack.CoverAlive())
{ if (m_GunTeam) m_GunTeam.Pause(); Clear(); State = 3; Deadline = now + 45000; Note("covering crew or mounted ammunition unavailable; manoeuvre paused"); }
if (State == 4 && vector.DistanceXZ(Position(), Approach) <= 30)
{
if (m_GunTeam)
{
if (Move(Sector, 25)) { State = 5; Deadline = now + 60000; Note("flank reached; original infantry investigating sector under mounted cover=" + Sector.ToString()); }
}
else
{
AIWaypoint search = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), Sector));
if (search) { search.SetCompletionRadius(25); Clear(); Add(search); State = 5; Deadline = now + 60000; Note("flank reached; searching reported sector=" + Sector.ToString()); }
}
}
if ((State == 4 || State == 5) && now >= Deadline)
{ State = 6; Deadline = now + 90000; Board(true); Note("bounded search complete; regrouping original survivors"); }
if (State == 3 && Role == 0 && now - Attack.Started >= 300000)
{ Stop(); return false; }
if (State == 6 && (Aboard(true) || now >= Deadline)) { Note("regroup ended; ordinary HQ travel may resume"); Stop(); return false; }
return Delivered && !Stopped;
}
void Stop()
{
if (Stopped) return;
if (m_GunTeam) { m_GunTeam.Stop(); m_GunTeam = null; }
VehicleUse(true);
Stopped = true; Radio = null; Clear();
ERR_HQ coordination = ERR_HQ.Get();
if (coordination && Unit) coordination.AttackSpace().Release(Unit.Group);
if (!Unit) return;
if (Delivered && Role == 0)
foreach (IEntity crew : Unit.Members) ERR_ConvoyMountedCrew.Release(crew);
if (Delivered) { Unit.CombatHeld = false; Unit.NextOrder = 0; }
}
}
class ERR_HunterCoordination
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_HunterAttack> m_Attacks = {};
protected float m_NextPlan;
void ERR_HunterCoordination(ERR_GameMode mode) { m_Mode = mode; }
bool Covered(vector position)
{
foreach (ERR_HunterAttack attack : m_Attacks)
{
if (vector.DistanceXZ(position, attack.ReportPosition) >= 300) continue;
int active;
foreach (ERR_HunterTactics team : attack.Teams) if (!team.Stopped && team.Living()) active++;
// A failed/finished partner must not reserve the entire encounter and
// prevent an available hunter receiving a fresh HQ interception order.
if (active >= 2) return true;
}
return false;
}
// Redirect an obsolete road approach only. Infantry already deployed or a
// gun with visual contact retains its local tactical ownership. An idle gun
// at the old sighting must not strand its still-travelling partner there.
protected void RefreshApproaches(ERR_HQBelief belief, float now)
{
if (!belief || !belief.Actionable() || !ERR_HunterAttackRules.Fresh(belief.ObservedAt, now)) return;
foreach (ERR_HunterAttack attack : m_Attacks)
{
if (attack.BeliefId != belief.BeliefId || belief.ObservedAt <= attack.Observed || vector.DistanceXZ(attack.ReportPosition, belief.PredictedPosition) < 250) continue;
bool travelling = true;
foreach (ERR_HunterTactics team : attack.Teams)
if (!team.Stopped && team.Living() && team.State > 1 && !(team.Role == 0 && team.State == 3 && !team.Covering())) travelling = false;
if (!travelling) continue;
foreach (ERR_HunterTactics old : attack.Teams) if (!old.Stopped) old.Stop();
ERR_HQ.Event("HUNTER COORDINATION", "new delivered sighting supersedes old road approach; original crews available for retask", belief.MessageId);
}
}
void Tick(ERR_HQ hq, array<ref ERR_HunterVehicle> hunters, float now)
{
for (int i = m_Attacks.Count() - 1; i >= 0; i--)
{
bool running;
foreach (ERR_HunterTactics old : m_Attacks[i].Teams) if (!old.Stopped) running = true;
if (!running) m_Attacks.Remove(i);
}
if (m_Attacks.Count() >= 5) return;
if (now < m_NextPlan) return;
m_NextPlan = now + 5000;
array<ref ERR_HQReport> reports = {}; hq.SupportContacts(reports, now);
ERR_HQBelief best;
foreach (ERR_HQReport report : reports)
{
ERR_HQBelief belief = hq.Memory().Get(report.Target);
RefreshApproaches(belief, now);
if (!belief || !belief.Actionable() || !ERR_HunterAttackRules.Fresh(belief.ObservedAt, now) || Covered(belief.PredictedPosition)) continue;
int nearby;
foreach (ERR_HunterVehicle candidate : hunters)
if (candidate.Registered && !candidate.Released && !candidate.CloseContactHeld && candidate.CrewValid(m_Mode) && (!candidate.Tactics || candidate.Tactics.Stopped) && vector.DistanceXZ(candidate.Entity.GetOrigin(), belief.PredictedPosition) <= 1800) nearby++;
if (nearby >= 2 && (!best || belief.ObservedAt > best.ObservedAt)) best = belief;
}
if (!best) return;
array<ERR_HunterVehicle> available = {};
foreach (ERR_HunterVehicle unit : hunters)
if (unit.Registered && !unit.Released && !unit.CloseContactHeld && unit.CrewValid(m_Mode) && (!unit.Tactics || unit.Tactics.Stopped) && vector.DistanceXZ(unit.Entity.GetOrigin(), best.PredictedPosition) <= 1800) available.Insert(unit);
if (available.Count() < 2) return;
ERR_HQ.Event("HUNTER COORDINATION", string.Format("%1 nearby crews available for delivered contact=%2", available.Count(), best.PredictedPosition), best.MessageId);
ERR_HunterAttack attack = new ERR_HunterAttack();
m_Attacks.Insert(attack);
attack.BeliefId = best.BeliefId; attack.ReportPosition = best.PredictedPosition; attack.Observed = best.ObservedAt; attack.Message = best.MessageId; attack.Started = now;
array<vector> occupied = {};
for (int role = 0; role < 3 && !available.IsEmpty(); role++)
{
int nearest = -1; float distance = 1e20;
vector desired = attack.ReportPosition;
if (role > 0) desired = ERR_HunterAttackRules.Offset(attack.ReportPosition, attack.Forward, role, 0);
for (int index = 0; index < available.Count(); index++)
{
if (role == 0 && !ERR_HunterTactics.CoverCrew(available[index], m_Mode)) continue;
float candidate = vector.DistanceXZ(available[index].Entity.GetOrigin(), desired);
if (candidate < distance) { distance = candidate; nearest = index; }
}
if (nearest < 0) break;
ERR_HunterVehicle selected = available[nearest]; available.Remove(nearest);
if (role == 0)
{
attack.Forward = attack.ReportPosition - selected.Entity.GetOrigin(); attack.Forward[1] = 0;
if (attack.Forward.Length() < 1) attack.Forward = "0 0 1";
attack.Forward.Normalize();
}
ERR_HunterTactics team = new ERR_HunterTactics(m_Mode, selected, attack, role);
if (!team.Plan(occupied) || !team.Queue(hq)) { team.Stop(); if (role == 0) break; continue; }
occupied.Insert(team.Stage); attack.Teams.Insert(team); selected.Tactics = team;
}
if (attack.Teams.Count() < 2)
{
foreach (ERR_HunterTactics incomplete : attack.Teams) incomplete.Stop();
m_Attacks.RemoveItem(attack);
}
}
void Stop()
{
foreach (ERR_HunterAttack attack : m_Attacks)
foreach (ERR_HunterTactics team : attack.Teams) team.Stop();
m_Attacks.Clear();
}
}
@@ -0,0 +1,676 @@
// Original convoy tactics adapted to native AI waypoints. No old framework hooks.
class ERR_SupplyTeam
{
SCR_AIGroup Group;
ref array<IEntity> Members = {};
ref array<BaseCompartmentSlot> Seats = {};
ref array<AIWaypoint> Orders = {};
bool Registered;
bool Released;
vector OrderedContact;
vector SearchPosition;
int SearchStep;
bool SearchPlanned;
ref ERR_ConvoyBoardingProgress BoardingProgress;
float BoardingRetryAt;
protected Vehicle m_WalkVehicle;
void VehicleUse(ERR_GameMode mode, Vehicle truck, bool enabled)
{
if (!Group || !truck) return;
SCR_AIGroupUtilityComponent utility = Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(truck.FindComponent(SCR_AIVehicleUsageComponent));
if (!utility || !usage) return;
if (!enabled && !m_WalkVehicle) { utility.RemoveUsableVehicle(usage); m_WalkVehicle = truck; }
else if (enabled && m_WalkVehicle == truck)
{
if (mode && mode.Active() && Living() && Group.GetFaction() == mode.EnemyFaction() && !ERR_ConvoyRules.Captured(truck, Group)) utility.AddUsableVehicle(usage);
m_WalkVehicle = null;
}
}
void Clear()
{
foreach (AIWaypoint waypoint : Orders)
{
if (Group && waypoint) Group.RemoveWaypoint(waypoint);
if (waypoint) SCR_EntityHelper.DeleteEntityAndChildren(waypoint);
}
Orders.Clear();
}
void Add(AIWaypoint waypoint)
{
if (!waypoint) return;
if (!Group || Released) { SCR_EntityHelper.DeleteEntityAndChildren(waypoint); return; }
Orders.Insert(waypoint); Group.AddWaypoint(waypoint);
}
void Release(ERR_GameMode mode)
{
if (Released) return;
// Only a live regroup may restore transport. Released foot survivors must
// not reclaim a truck after its crew was lost or player capture was latched.
Released = true; Clear();
ERR_HQ coordination = ERR_HQ.Get(); if (coordination) coordination.AttackSpace().Release(Group);
if (!Group) return;
ERR_HQ hq;
if (mode) hq = ERR_HQ.Cast(mode.Command());
bool retained = false;
if (Registered && hq) retained = hq.ReleaseConvoySurvivors(Group);
if (!retained && mode && mode.Command()) mode.Command().UnregisterGroup(Group);
ERR_ForceRecord record;
if (mode && mode.Forces()) record = mode.Forces().FindGroup(Group);
if (record) record.Pinned = false;
Group.SetPermanentLOD(-1);
array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1);
}
int BoardingCount(Vehicle truck, out float remainingDistance)
{
int seated; remainingDistance = 0;
foreach (IEntity member : Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group) continue;
if (CompartmentAccessComponent.GetVehicleIn(member) == truck) seated++;
else remainingDistance = Math.Max(remainingDistance, vector.DistanceXZ(member.GetOrigin(), truck.GetOrigin()));
}
return seated;
}
bool Living()
{
foreach (IEntity member : Members)
if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Group) return true;
return false;
}
bool Aboard(Vehicle truck, bool required)
{
foreach (IEntity member : Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Group(member) != Group) continue;
if ((CompartmentAccessComponent.GetVehicleIn(member) == truck) != required) return false;
}
return true;
}
}
// Only the exact original vehicle crews are retained. Infantry never enters this map.
class ERR_ConvoyMountedCrew
{
protected static ref map<IEntity, Vehicle> s_Crews = new map<IEntity, Vehicle>();
protected static ref map<IEntity, int> s_Couriers = new map<IEntity, int>();
static bool BeginCourier(IEntity actor, Vehicle originalVehicle, int token)
{
if (!actor || !originalVehicle || token <= 0 || !s_Crews.Contains(actor) || s_Crews.Get(actor) != originalVehicle || s_Couriers.Contains(actor)) return false;
s_Couriers.Set(actor, token); return true;
}
static void EndCourier(IEntity actor, int token)
{
if (actor && s_Couriers.Contains(actor) && s_Couriers.Get(actor) == token) s_Couriers.Remove(actor);
}
static void Hold(IEntity actor, Vehicle vehicle) { if (actor && vehicle) s_Crews.Set(actor, vehicle); }
static void Release(IEntity actor) { if (actor) { s_Crews.Remove(actor); s_Couriers.Remove(actor); } }
protected static bool RetainOriginal(IEntity actor, IEntity vehicle)
{
if (!actor || !vehicle || !s_Crews.Contains(actor) || s_Crews.Get(actor) != vehicle || ERR_Actors.Player(actor) || !ERR_Actors.CanFight(actor)) return false;
if (CompartmentAccessComponent.GetVehicleIn(actor) != vehicle) return false;
SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(vehicle.FindComponent(SCR_VehicleDamageManagerComponent));
// Let native emergency abandonment take over on critical damage or drivetrain failure.
return damage && damage.GetState() != EDamageState.DESTROYED && damage.GetHealthScaled() > 0.25 && damage.GetEngineFunctional() && damage.GetGearboxFunctional();
}
// Individual permission never authorizes a whole-group dismount.
static bool Retain(IEntity actor, IEntity vehicle)
{
return !s_Couriers.Contains(actor) && RetainOriginal(actor, vehicle);
}
static bool RetainGroup(SCR_AIGroup group, IEntity vehicle)
{
if (!group || group.GetPlayerCount(true) > 0) return false;
if (!vehicle)
{
IEntity leader = group.GetLeaderEntity();
if (!leader || !s_Crews.Contains(leader)) return false;
vehicle = s_Crews.Get(leader);
}
if (!vehicle) return false;
array<AIAgent> agents = {}; group.GetAgents(agents);
if (agents.IsEmpty() || agents.Count() > 32) return false;
foreach (AIAgent agent : agents)
{
if (!agent) continue;
IEntity actor = agent.GetControlledEntity();
if (actor && ERR_Actors.Group(actor) == group && RetainOriginal(actor, vehicle)) return true;
}
return false;
}
}
modded class SCR_AIGetOutVehicle
{
override float CustomEvaluate()
{
if (m_Utility && ERR_ConvoyMountedCrew.Retain(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0;
return super.CustomEvaluate();
}
override float EvaluatePriorityLevel()
{
if (m_Utility && ERR_ConvoyMountedCrew.Retain(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0;
return super.EvaluatePriorityLevel();
}
}
modded class SCR_AIGetOutActivity
{
override float CustomEvaluate()
{
if (m_Utility && ERR_ConvoyMountedCrew.RetainGroup(m_Utility.m_Owner, m_Vehicle.m_Value)) return 0;
return super.CustomEvaluate();
}
override float EvaluatePriorityLevel()
{
if (m_Utility && ERR_ConvoyMountedCrew.RetainGroup(m_Utility.m_Owner, m_Vehicle.m_Value)) return 0;
return super.EvaluatePriorityLevel();
}
}
class ERR_SupplyConvoyVehicle : ERR_ConvoyVehicle
{
override bool CrewValid(ERR_GameMode mode)
{
if (!Group || !Entity || !ERR_Actors.Alive(Entity) || Group.GetFaction() != mode.EnemyFaction() || !ERR_Actors.CanFight(Driver)) return false;
if (ERR_Actors.Player(Driver) || ERR_Actors.Group(Driver) != Group || !ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(Driver), Entity)) return false;
foreach (IEntity member : Members)
if (ERR_Actors.Alive(member) && (ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group)) return false;
return true;
}
override void Release(ERR_GameMode mode)
{
foreach (IEntity member : Members) ERR_ConvoyMountedCrew.Release(member);
super.Release(mode);
}
}
class ERR_SupplyConvoyTask : ERR_ConvoyTask
{
ref array<ref ERR_SupplyTeam> Teams = {};
ref ERR_SupplyConvoyTactics Tactics;
ref ERR_SupplyConvoyColumn Column;
ref array<vector> Bases = {};
ref array<vector> Starts = {};
int LastBase = -1;
int Legs;
float Departure;
float TravelDeadline;
bool Waiting;
bool MagazinesIssued;
ref array<ref ERR_MountedMagazineSeed> MountedStock = {};
ref array<IEntity> CargoBefore = {};
ref ERR_ConvoyMineStock MineStock;
override void Stop(ERR_GameMode mode, string reason)
{
if (Stopped) return;
if (Tactics) Tactics.Stop();
if (MineStock) MineStock.Stop();
foreach (ERR_SupplyTeam team : Teams) team.Release(mode);
super.Stop(mode, reason);
}
}
class ERR_SupplyConvoyTactics
{
protected ERR_GameMode m_Mode;
protected ERR_SupplyConvoyTask m_Task;
protected ref array<ref ERR_SupplyTeam> m_Support = {};
protected int m_State; // mounted, fighting, reboarding
protected vector m_Contact;
protected vector m_Forward;
protected vector m_Left;
protected float m_LastContact = -1;
protected float m_NextOrders;
protected float m_NextSearch;
protected vector m_LastOrderContact;
protected bool m_OrdersIssued;
protected float m_BoardDeadline;
protected bool m_TruckWithdrawn;
protected float m_NextDismount;
protected int m_DismountAttempts;
protected bool m_Stopped;
void ERR_SupplyConvoyTactics(ERR_GameMode mode, ERR_SupplyConvoyTask task)
{
m_Mode = mode; m_Task = task;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{
ERR_SupplyTeam order = new ERR_SupplyTeam(); order.Group = vehicle.Group;
m_Support.Insert(order);
foreach (IEntity member : vehicle.Members) ERR_ConvoyMountedCrew.Hold(member, vehicle.Entity);
}
}
int State() { return m_State; }
static AIWaypoint Move(vector position, float radius = 20)
{
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), position));
if (waypoint) waypoint.SetCompletionRadius(radius);
return waypoint;
}
static void Command(AIWaypoint order)
{
SCR_AIWaypoint commanded = SCR_AIWaypoint.Cast(order);
if (commanded) commanded.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER);
}
protected bool FreePosition(SCR_AIGroup group, vector position)
{
ERR_HQ hq = ERR_HQ.Get();
return !hq || hq.AttackSpace().Free(group, position, GetGame().GetWorld().GetWorldTime());
}
protected void Claim(SCR_AIGroup group, vector position, float now)
{
ERR_HQ hq = ERR_HQ.Get(); if (hq) hq.AttackSpace().Claim(group, position, now);
}
static AIWaypoint Board(Vehicle truck, bool enter)
{
if (!truck) return null;
ResourceName prefab = "{C40316EE26846CAB}Prefabs/AI/Waypoints/AIWaypoint_GetOut.et";
if (enter) prefab = "{712F4795CF8B91C7}Prefabs/AI/Waypoints/AIWaypoint_GetIn.et";
SCR_BoardingWaypoint waypoint = SCR_BoardingWaypoint.Cast(ERR_Actors.Spawn(prefab, truck.GetOrigin()));
if (!waypoint) return null;
Command(waypoint);
waypoint.SetAllowance(false, false, true);
// Native defaults may complete when only the leader has left/boarded.
waypoint.SetCompletionType(EAIWaypointCompletionType.All);
// The waypoint approach applies to the whole team; leave space around the truck.
waypoint.SetCompletionRadius(100);
SCR_BoardingEntityWaypoint specific = SCR_BoardingEntityWaypoint.Cast(waypoint);
if (specific) specific.SetEntity(truck);
return waypoint;
}
protected bool Usable(int index)
{
return index >= 0 && index < m_Task.Vehicles.Count() && !m_Task.Vehicles[index].Released && m_Task.Vehicles[index].CrewValid(m_Mode);
}
protected bool InfantryAboard(bool required)
{
foreach (ERR_SupplyTeam team : m_Task.Teams)
if (!team.Released && !team.Aboard(m_Task.Vehicles[2].Entity, required)) return false;
return true;
}
protected float DistanceToConvoy(vector position)
{
float distance = 1e20;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
if (!vehicle.Released && vehicle.Entity) distance = Math.Min(distance, vector.DistanceXZ(vehicle.Entity.GetOrigin(), position));
return distance;
}
protected bool Observe(SCR_AIGroup group, out vector position, inout float nearest)
{
if (!group || !group.GetGroupUtilityComponent()) return false;
SCR_AIGroupPerception perception = group.GetGroupUtilityComponent().m_Perception;
PerceptionManager manager = GetGame().GetPerceptionManager();
if (!perception || !manager) return false;
bool found;
foreach (SCR_AITargetInfo target : perception.m_aTargets)
{
if (!target || !target.m_Entity || !target.m_Faction || !group.GetFaction().IsFactionEnemy(target.m_Faction)) continue;
float age = manager.GetTime() - target.m_fTimestamp;
if (target.m_eCategory != EAITargetInfoCategory.IDENTIFIED || age < 0 || age > 5) continue;
float distance = DistanceToConvoy(target.m_vWorldPos);
if (distance > nearest || !group.GetGroupUtilityComponent().IsPositionAllowed(target.m_vWorldPos)) continue;
position = target.m_vWorldPos; nearest = distance; found = true;
}
return found;
}
// Only production native observations or a delivered HQ snapshot may call this.
void Report(vector position, float observed, float now)
{
if (m_Stopped || !ERR_ForceRules.Finite(now) || !ERR_ForceRules.Finite(observed) || !ERR_ForceRules.FiniteVector(position) || observed < 0 || observed > now || now - observed > 60000 || observed <= m_LastContact) return;
m_LastContact = observed;
if (m_State != 1) Start(position, now);
else if (vector.DistanceXZ(position, m_Contact) > 40 && now >= m_NextOrders) { m_Contact = position; Orders(false, now); }
}
protected bool FootPosition(ERR_SupplyTeam team, vector desired, out vector position)
{
if (!team.Group || !team.Group.GetGroupUtilityComponent()) return false;
for (int i = 0; i < 8; i++)
{
vector point = desired;
if (i > 0) point += Vector(Math.RandomFloat(-12, 12), 0, Math.RandomFloat(-12, 12));
point = ERR_Actors.Ground(point);
if (FreePosition(team.Group, point) && !ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) && team.Group.GetGroupUtilityComponent().IsPositionAllowed(point) && SCR_WorldTools.TraceCilinderUtil(point + "0 1 0", 1, 2)) { position = point; return true; }
}
return false;
}
protected void Flank(int index, bool dismount)
{
PlanFlank(index, dismount, 0);
}
protected bool PlanFlank(int index, bool dismount, int step)
{
ERR_SupplyTeam team = m_Task.Teams[index];
if (team.Released || !team.Living() || step < 0 || step > 2) return false;
// Preserve an in-progress native all-member dismount across contact refreshes.
if (!team.Aboard(m_Task.Vehicles[2].Entity, false) && SCR_BoardingWaypoint.Cast(team.Group.GetCurrentWaypoint())) return false;
// Do not queue a vehicle-capable Move behind GetOut: native AI may board
// again before our next tick. Start foot movement only after the whole team
// has actually left and its shared truck is detached from this group's usage.
if (!team.Aboard(m_Task.Vehicles[2].Entity, false))
{
if (!dismount) return false;
AIWaypoint disembark = Board(m_Task.Vehicles[2].Entity, false);
if (!disembark) return false;
team.Clear(); team.SearchPlanned = false; team.Add(disembark); return true;
}
float side = 1; if (index == 1) side = -1;
vector desired = m_Contact + m_Left * (70 * side);
if (step == 1) desired = m_Contact + m_Forward * 100 + m_Left * (140 * side);
if (step == 2) desired = m_Contact + m_Forward * 160 + m_Left * (50 * side);
vector approach, sector;
if (!FootPosition(team, desired, sector)) return false;
approach = sector;
if (step == 0 && !FootPosition(team, m_Contact - m_Forward * 80 + m_Left * (110 * side), approach)) return false;
AIWaypoint move = Move(approach, 15);
AIWaypoint search = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), sector));
// Failed placement/allocation must not erase the team's working orders.
if (!move || !search)
{
if (move) SCR_EntityHelper.DeleteEntityAndChildren(move);
if (search) SCR_EntityHelper.DeleteEntityAndChildren(search);
return false;
}
Command(move); Command(search);
team.Clear(); team.OrderedContact = m_Contact;
team.SearchPosition = sector; team.SearchStep = step; team.SearchPlanned = true;
team.Add(move);
search.SetCompletionRadius(30);
team.Add(search);
Claim(team.Group, sector, GetGame().GetWorld().GetWorldTime());
ERR_HQ.Event("CONVOY HUNT", string.Format("%1 team %2: search step %3/3 at %4 from last observed contact; formation suspended", m_Task.Receipt.SourceId, index + 1, step + 1, sector));
return true;
}
protected void ContinueSearch(float now)
{
if (now < m_NextSearch) return;
m_NextSearch = now + 45000;
for (int i = 0; i < m_Task.Teams.Count(); i++)
{
ERR_SupplyTeam team = m_Task.Teams[i];
if (team.Released || !team.Living() || !team.Aboard(m_Task.Vehicles[2].Entity, false)) continue;
// Retry refused initial orders, but never replace a team still travelling.
if (!team.SearchPlanned || vector.DistanceXZ(team.OrderedContact, m_Contact) > 40) { PlanFlank(i, false, 0); continue; }
if (team.SearchStep >= 2) continue;
IEntity leader = team.Group.GetLeaderEntity();
if (!leader || vector.DistanceXZ(leader.GetOrigin(), team.SearchPosition) > 45) continue;
PlanFlank(i, false, team.SearchStep + 1);
}
}
protected bool SupportPosition(int index, vector desired, out vector position)
{
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent();
if (!utility) return false;
vector forward = ERR_MountedCover.Approach(vehicle.Entity.GetOrigin(), m_Contact, m_Forward);
bool armed = index != 1 && index != 2;
vector road;
if (ERR_SupplyConvoySystem.Approach(desired, road, 70) && FreePosition(vehicle.Group, road) && vector.Dot(road - m_Contact, forward) <= -100 && utility.IsPositionAllowed(road) && (!armed || ERR_MountedCover.ClearLane(vehicle.Entity, road, m_Contact))) { position = road; return true; }
if (index == 1 || index == 2) return false;
// Original armed escorts can seek a nearby open firing position off the road.
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
for (int attempt = 0; attempt < 8; attempt++)
{
vector candidate = desired;
if (attempt > 0) candidate += Vector(Math.RandomFloat(-80, 80), 0, Math.RandomFloat(-80, 80));
candidate = ERR_Actors.Ground(candidate);
if (!FreePosition(vehicle.Group, candidate) || vector.Dot(candidate - m_Contact, forward) > -100 || !utility.IsPositionAllowed(candidate)) continue;
if (!ERR_MountedCover.ClearLane(vehicle.Entity, candidate, m_Contact)) continue;
if (placement.Clear(candidate, ERR_ForceRules.Heading(candidate, m_Contact, 0), 2.5)) { position = candidate; return true; }
}
return false;
}
protected void Support(int index, bool holdTruck, bool retainApproach = false)
{
ERR_SupplyTeam orders = m_Support[index];
if (!Usable(index)) { orders.Clear(); return; }
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
// An idle mounted driver stops without a Defend order asking the crew to exit.
if (index == 2 && holdTruck) { orders.Clear(); return; }
bool armed = index != 1 && index != 2;
float range = vector.DistanceXZ(vehicle.Entity.GetOrigin(), m_Contact);
// Keep a gun that can already see the reported fight. Do not back it away
// just to satisfy the convoy formation's fixed rear/side offsets.
if (armed && range >= 100 && range <= 600 && ERR_MountedCover.SeesSector(vehicle.Group, m_Contact))
{
orders.Clear(); Claim(vehicle.Group, vehicle.Entity.GetOrigin(), GetGame().GetWorld().GetWorldTime());
ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": holding visual contact at " + vehicle.Entity.GetOrigin().ToString());
return;
}
// Periodic cover checks must not restart a native driver still approaching.
if (retainApproach && !orders.Orders.IsEmpty() && orders.Orders[0] && vector.DistanceXZ(vehicle.Entity.GetOrigin(), orders.Orders[0].GetOrigin()) > 30) return;
float rear = 180, side;
if (index == 0) side = 150;
if (index == 3) side = -150;
if (index == 5) rear = 250;
if (index == 1) { rear = 350; side = 40; }
if (index == 2) { rear = 310; side = -40; }
vector support = vehicle.Entity.GetOrigin();
vector forward = ERR_MountedCover.Approach(vehicle.Entity.GetOrigin(), m_Contact, m_Forward);
vector left = Vector(-forward[2], 0, forward[0]);
vector desired = m_Contact - forward * rear + left * side;
vector chosen;
if (SupportPosition(index, desired, chosen)) support = chosen;
else if (armed)
{
orders.Clear(); Claim(vehicle.Group, support, GetGame().GetWorld().GetWorldTime());
ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": no clear firing lane found; holding and retrying at " + support.ToString());
return;
}
if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20)
{
AIWaypoint movement = Move(support);
if (!movement) return;
Command(movement); orders.Clear(); orders.Add(movement);
ERR_HQ.Event("CONVOY SUPPORT", string.Format("%1 vehicle %2: position=%3 destination=%4 reported contact=%5 armed=%6", m_Task.Receipt.SourceId, index, vehicle.Entity.GetOrigin(), support, m_Contact, armed));
}
else orders.Clear();
Claim(vehicle.Group, support, GetGame().GetWorld().GetWorldTime());
if (index == 1 || index == 2) return;
// Mounted escorts engage using native vehicle combat after reaching cover.
// Defend is an infantry occupation task; deleting it during regroup could
// leave ActivityDefend executing with an already deleted waypoint.
// Do not assign it to a crew whose role requires remaining in the vehicle.
}
protected void Clear()
{
foreach (ERR_SupplyTeam orders : m_Support) orders.Clear();
foreach (ERR_SupplyTeam team : m_Task.Teams) team.Clear();
}
protected void Orders(bool dismount, float now)
{
m_NextOrders = now + 30000;
if (m_OrdersIssued && vector.DistanceXZ(m_LastOrderContact, m_Contact) < 40)
{
// Recheck masked firing positions even when the reported target is stationary.
for (int support = 0; support < m_Task.Vehicles.Count(); support++) Support(support, !m_TruckWithdrawn, true);
return;
}
m_LastOrderContact = m_Contact; m_OrdersIssued = true;
for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, !m_TruckWithdrawn);
for (int i = 0; i < m_Task.Teams.Count(); i++) Flank(i, dismount);
m_NextOrders = now + 30000;
}
protected void Start(vector contact, float now)
{
// A local threat takes precedence, but may release only this exact detour owner.
foreach (ERR_ConvoyVehicle intercept : m_Task.Vehicles)
{
if (intercept.InterceptToken == 0) continue;
intercept.ClearIntercept(intercept.InterceptOrder);
if (intercept.InterceptToken != 0) return;
}
Clear(); m_State = 1; m_Contact = contact; m_TruckWithdrawn = m_Task.Teams.IsEmpty(); m_OrdersIssued = false;
m_NextSearch = now + 45000;
foreach (ERR_SupplyTeam team : m_Task.Teams) { team.SearchPlanned = false; team.SearchStep = 0; }
if (m_Task.Column) m_Task.Column.Reset(now);
m_Task.Waiting = false;
vector origin = contact;
foreach (ERR_ConvoyVehicle stagingVehicle : m_Task.Vehicles)
if (!stagingVehicle.Released && stagingVehicle.Entity) { origin = stagingVehicle.Entity.GetOrigin(); break; }
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
vehicle.ClearOrder();
if (!vehicle.Released) vehicle.Combat.Held = true;
}
m_Forward = contact - origin; m_Forward[1] = 0;
if (m_Forward.Length() < 1) m_Forward = "0 0 1";
m_Forward.Normalize(); m_Left = Vector(-m_Forward[2], 0, m_Forward[0]);
Orders(true, now);
m_DismountAttempts = 0; m_NextDismount = now + 10000;
ERR_HQ.Event("CONVOY COMBAT", m_Task.Receipt.SourceId + ": armed escorts take covering positions; ammo truck withdraws");
}
protected void Finish(float now)
{
Clear(); m_State = 0;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
vehicle.Combat.Held = false; vehicle.Arrived = false;
// Combat time does not consume the driving deadline.
vehicle.LegDeadline = now + 600000;
}
m_Task.TravelDeadline = now + 600000;
if (m_Task.Column) m_Task.Column.Reset(now, false, true);
ERR_HQ.Event("CONVOY REGROUP", m_Task.Receipt.SourceId + ": surviving convoy resumes; no replacements");
}
protected float InfantryDistance(IEntity vehicle)
{
float distance = 1e20;
if (!vehicle) return distance;
foreach (ERR_SupplyTeam team : m_Task.Teams)
{
if (team.Released) continue;
foreach (IEntity member : team.Members)
{
if (!ERR_Actors.CanFight(member) || CompartmentAccessComponent.GetVehicleIn(member)) continue;
distance = Math.Min(distance, vector.DistanceXZ(member.GetOrigin(), vehicle.GetOrigin()));
}
}
return distance;
}
protected void ProtectInfantry()
{
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
if (vehicle.Released || !vehicle.Entity) continue;
AICarMovementComponent movement = AICarMovementComponent.Cast(vehicle.Entity.FindComponent(AICarMovementComponent));
if (!movement) continue;
float speed = 35;
if (InfantryDistance(vehicle.Entity) < 45) speed = 8;
movement.SetCruiseSpeed(speed);
}
}
protected void RefreshBoarding(float now)
{
foreach (ERR_SupplyTeam team : m_Task.Teams)
{
if (team.Released || team.Aboard(m_Task.Vehicles[2].Entity, true)) continue;
if (team.BoardingRetryAt > 0)
{
if (now < team.BoardingRetryAt) continue;
team.BoardingRetryAt = 0;
team.Add(Board(m_Task.Vehicles[2].Entity, true));
continue;
}
if (!team.BoardingProgress) team.BoardingProgress = new ERR_ConvoyBoardingProgress();
float distance; int seated = team.BoardingCount(m_Task.Vehicles[2].Entity, distance);
if (!team.BoardingProgress.Retry(seated, distance, now)) continue;
// Let the old native activity release reservations before asking for seats
// again. Occupants remain seated; no direct compartment move is performed.
team.Clear(); team.BoardingRetryAt = now + 1000;
ERR_HQ.Event("CONVOY BOARDING RETRY", string.Format("%1: team has %2 aboard; farthest remaining soldier %3 m from truck. No approach/seat progress for 30 s; retry %4/3", m_Task.Receipt.SourceId, seated, Math.Round(distance), team.BoardingProgress.Retries));
}
}
bool Tick(float now)
{
if (m_Stopped) return false;
for (int released = 0; released < m_Task.Vehicles.Count(); released++)
if (m_Task.Vehicles[released].Released) m_Support[released].Clear();
foreach (ERR_SupplyTeam infantryTeam : m_Task.Teams)
{
if (infantryTeam.Released) continue;
bool foreign = !infantryTeam.Group || infantryTeam.Group.GetFaction() != m_Mode.EnemyFaction();
foreach (IEntity member : infantryTeam.Members)
if (ERR_Actors.Alive(member) && ERR_Actors.Group(member) != infantryTeam.Group) foreign = true;
if (foreign || !infantryTeam.Living()) infantryTeam.Release(m_Mode);
}
vector observed; float nearest = 400; bool found;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
if (!vehicle.Released && Observe(vehicle.Group, observed, nearest)) found = true;
foreach (ERR_SupplyTeam team : m_Task.Teams)
if (!team.Released && Observe(team.Group, observed, nearest)) found = true;
if (found) Report(observed, now, now);
if (m_State == 1)
{
for (int walking = 0; walking < m_Task.Teams.Count(); walking++)
{
ERR_SupplyTeam foot = m_Task.Teams[walking];
if (foot.Released || !foot.Living() || !foot.Aboard(m_Task.Vehicles[2].Entity, false)) continue;
foot.VehicleUse(m_Mode, m_Task.Vehicles[2].Entity, false);
if (!foot.SearchPlanned) PlanFlank(walking, false, 0);
if (foot.SearchPlanned) Claim(foot.Group, foot.SearchPosition, now);
}
for (int supporting = 0; supporting < m_Support.Count(); supporting++)
{
ERR_ConvoyVehicle covering = m_Task.Vehicles[supporting];
if (covering.Released || !covering.Entity) continue;
vector coverPosition = covering.Entity.GetOrigin();
if (!m_Support[supporting].Orders.IsEmpty() && m_Support[supporting].Orders[0])
coverPosition = m_Support[supporting].Orders[0].GetOrigin();
Claim(covering.Group, coverPosition, now);
}
if (!m_TruckWithdrawn && InfantryAboard(false) && InfantryDistance(m_Task.Vehicles[2].Entity) >= 25) { m_TruckWithdrawn = true; Support(2, false); }
// Native exit attempts can fail while the truck brakes or a door is busy.
// Retry only still-mounted teams, never teleport actors or reset quiet time.
if (!m_TruckWithdrawn && !InfantryAboard(false) && Usable(2) && now >= m_NextDismount && m_DismountAttempts < 6)
{
m_DismountAttempts++; m_NextDismount = now + 10000;
for (int retry = 0; retry < m_Task.Teams.Count(); retry++)
{
ERR_SupplyTeam delayed = m_Task.Teams[retry];
if (delayed.Released || delayed.Aboard(m_Task.Vehicles[2].Entity, false)) continue;
delayed.Clear(); Flank(retry, true);
}
}
if (now >= m_NextOrders && now - m_LastContact < 180000) Orders(!InfantryAboard(false), now);
// Search only remembered positions. Real new sightings can redirect the
// teams; silence gives them time to reach and sweep both flanks, not a
// one-minute dash followed by forced reboarding.
if (!found && now - m_LastContact < 180000) ContinueSearch(now);
if (now - m_LastContact >= 180000)
{
Clear();
if (!Usable(2)) { foreach (ERR_SupplyTeam lost : m_Task.Teams) lost.Release(m_Mode); Finish(now); return false; }
for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, true);
foreach (ERR_SupplyTeam boarding : m_Task.Teams)
if (!boarding.Released && boarding.Living())
{
boarding.VehicleUse(m_Mode, m_Task.Vehicles[2].Entity, true);
boarding.BoardingProgress = new ERR_ConvoyBoardingProgress(); boarding.BoardingRetryAt = 0;
boarding.Add(Board(m_Task.Vehicles[2].Entity, true));
}
m_State = 2; m_BoardDeadline = now + 300000;
ERR_HQ.Event("CONVOY BOARDING", m_Task.Receipt.SourceId + ": both teams return under escort cover");
}
}
if (m_State == 2 && Usable(2) && now < m_BoardDeadline) RefreshBoarding(now);
if (m_State == 2 && (!Usable(2) || InfantryAboard(true) || now >= m_BoardDeadline))
{
foreach (ERR_SupplyTeam stranded : m_Task.Teams)
if (!stranded.Aboard(m_Task.Vehicles[2].Entity, true)) stranded.Release(m_Mode);
Finish(now);
}
if (m_State != 0) ProtectInfantry();
return m_State != 0;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true; Clear();
ERR_HQ hq = ERR_HQ.Get();
foreach (ERR_SupplyTeam team : m_Task.Teams)
{
if (hq) hq.AttackSpace().Release(team.Group);
}
foreach (ERR_SupplyTeam support : m_Support) if (hq) hq.AttackSpace().Release(support.Group);
}
}
@@ -0,0 +1,366 @@
"""Run the production column controller against physical-position/native-order doubles."""
from policy_harness import ROOT, body, run
import re,os
from pathlib import Path
p=Path(os.environ.get('ERR_SUPPLY_COLUMN_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'))
def cpp(s):
s=re.sub(r'//[^\n]*', '', s).replace('protected ', '').replace('out vector ', 'vector& ')
s=s.replace('foreach (', 'for (').replace('null','nullptr')
for typ in ('ERR_ConvoyVehicle','ERR_ConvoyColumnVehicle'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
for obj in ('m_Task','vehicle','state','scan','promoted','ahead','m_State[i]','currentLeader','m_Task->Vehicles[previous]'):
s=s.replace(obj+'.',obj+'->')
for obj in ('Entity','Group','Waypoint','Route','Receipt','Combat'):
s=s.replace(obj+'.',obj+'->')
for cls in ('ERR_ConvoyColumnRules','ERR_ConvoyDriving','ERR_SupplyConvoyTactics','ERR_HQ','Math','vector','string'):
s=s.replace(cls+'.',cls+'::')
s=s.replace('array<int>', 'Array<int>').replace('m_State[continuing].', 'm_State[continuing]->').replace('m_State[detached].', 'm_State[detached]->')
s=s.replace('bool rejoining;', 'bool rejoining=false;').replace('m_State[returning].','m_State[returning]->').replace('m_Task->Vehicles[returning].','m_Task->Vehicles[returning]->')
s=s.replace('m_State[previous].','m_State[previous]->')
s=s.replace('m_State[index].', 'm_State[index]->').replace('m_State[leader].', 'm_State[leader]->').replace('m_State[survivor].', 'm_State[survivor]->')
s=s.replace('m_Task->Vehicles[index].', 'm_Task->Vehicles[index]->').replace('(index + 1).ToString()', 'std::to_string(index + 1)')
s=s.replace('float largestGap; bool supplyHold; bool interceptLive;', 'float largestGap=0; bool supplyHold=false; bool interceptLive=false;')
return s
rules='\n'.join(cpp(body(p, x)) for x in ('static bool WaitForRear(', 'static bool TooClose(', 'static float Speed(', 'static bool StraightAhead('))
methods='\n'.join(cpp(body(p,x)) for x in ('protected float DistanceToTarget(', 'protected void Reorder(', 'void Reset(', 'protected void Hold(', 'protected bool Progress(', 'bool Tick('))
trail='\n'.join(cpp(body(p,x)) for x in ('void Record(', 'bool Target('))
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
#include <string>
struct string:std::string{using std::string::string;string(std::string s):std::string(s){}template<class...T>static string Format(T...){return "event";}};
struct vector{float x=0,z=0;float& operator[](int i){return i==0?x:z;}vector operator+(vector b){return{x+b.x,z+b.z};}vector operator-(vector b){return{x-b.x,z-b.z};}vector operator*(float n){return{x*n,z*n};}static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}};
namespace Math{float Max(float a,float b){return a>b?a:b;}int Min(int a,int b){return a<b?a:b;}float Round(float x){return std::round(x);}}
namespace ERR_HQ{int stranded=0,retries=0;void Event(string type,string){if(type=="CONVOY STRANDED")stranded++;if(type=="CONVOY ROUTE RETRY")retries++;}}
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}void Insert(T value){this->push_back(value);}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Remove(int i){this->erase(this->begin()+i);}};
struct ERR_ConvoyTrail{Array<vector>Points;'''+trail+r'''};
struct ERR_ConvoyColumnRules{'''+rules+r'''};
struct Entity{vector position;vector GetOrigin(){return position;}};
struct Waypoint:Entity{};
struct Group{int added=0;void AddWaypoint(Waypoint*){added++;}};
struct Route{Array<vector>points={{0},{400}};int Count(){return points.Count();}vector At(int i){return points[i];}};
struct Combat{bool Held=false;};
struct ERR_ConvoyVehicle{
int Index=0,RouteIndex=0,clears=0;bool InterceptActive=false,Released=false,Arrived=false,SupplyHeld=false;
struct Entity entity;struct Entity* Entity=&entity;struct Group group;struct Group* Group=&group;
struct Combat combat;struct Combat* Combat=&combat;struct Waypoint* Waypoint=nullptr;
void ClearOrder(){if(Waypoint)clears++;delete Waypoint;Waypoint=nullptr;}
void Release(void*){Released=true;ClearOrder();}
bool Order(struct Route* route,float){if(!Waypoint){Waypoint=new struct Waypoint();Waypoint->position=route->At(RouteIndex);Group->AddWaypoint(Waypoint);}return true;}
~ERR_ConvoyVehicle(){ClearOrder();}
};
namespace ERR_ConvoyDriving{bool Restore(void*,ERR_ConvoyVehicle*){return false;}}
namespace ERR_SupplyConvoyTactics{Waypoint* Move(vector p,float){auto w=new Waypoint();w->position=p;return w;}}
struct Receipt{string SourceId="test";};
struct ERR_SupplyConvoyTask{Array<ERR_ConvoyVehicle*>Vehicles;struct Route route;struct Route* Route=&route;int Legs=0;struct Receipt receipt;struct Receipt* Receipt=&receipt;};
struct ERR_ConvoyColumnVehicle{ERR_ConvoyTrail Trail;bool InterceptDetached=false,Holding=false;vector Progress;float LastProgress=-1,NextRetry=0,NextTarget=0,Speed=-1;int Ahead=-1;};
struct Column{
void* m_Mode=nullptr;ERR_SupplyConvoyTask* m_Task;Array<ERR_ConvoyColumnVehicle*>m_State;
Array<int>m_Order={0,1,2};int m_Leader=-1,m_RouteIndex=0;bool m_RearWait=false;float m_NextLog=0,m_RearWaitAt=-1;
void Speed(ERR_ConvoyVehicle*,ERR_ConvoyColumnVehicle* state,float speed){state->Speed=speed;}
'''+methods+r'''};
int main(){
// No stop/arrival dead zone behind a parked leader, including an empty trail after reform.
for(float gap:{75.1f,78.f,80.f,90.f}){
ERR_ConvoyVehicle lead,tail;ERR_ConvoyColumnVehicle ls,ts;ERR_SupplyConvoyTask task;
lead.entity.position={400};tail.entity.position={400-gap};task.Vehicles={&lead,&tail};
Column column{nullptr,&task,{&ls,&ts}};column.m_Order={0,1};column.m_RouteIndex=1;column.Tick(0);
assert(lead.Arrived&&!tail.Arrived&&tail.Waypoint&&tail.Waypoint->position.x==365);
tail.entity.position={326};column.Tick(1000);assert(tail.Arrived&&!tail.Waypoint);
}
// Dense straight samples select one lookahead target, then retain it while driving.
{ERR_ConvoyVehicle lead;ERR_ConvoyColumnVehicle state;ERR_SupplyConvoyTask task;
task.Vehicles={&lead};task.route.points={{0},{20},{40},{60},{80},{100},{120},{140},{160},{180},{200}};
Column column{nullptr,&task,{&state}};column.m_Order={0};column.Tick(0);
assert(lead.RouteIndex==5&&lead.Waypoint->position.x==100&&lead.group.added==1);
lead.entity.position={20};column.Tick(1000);assert(lead.RouteIndex==5&&lead.group.added==1);
lead.entity.position={60};column.Tick(2000);assert(lead.RouteIndex==5&&lead.group.added==1);
lead.entity.position={70};column.Tick(3000);assert(lead.RouteIndex==8&&lead.Waypoint->position.x==160&&lead.group.added==2);
lead.entity.position={150};column.Tick(4000);assert(!lead.Arrived&&lead.Waypoint->position.x==200);
lead.entity.position={164};column.Tick(5000);assert(!lead.Arrived);
lead.entity.position={165};column.Tick(6000);assert(lead.Arrived);
}
assert(ERR_ConvoyColumnRules::StraightAhead({0,0},{70,0},{150,0}));
assert(!ERR_ConvoyColumnRules::StraightAhead({0,0},{81,0},{150,0}));
assert(!ERR_ConvoyColumnRules::StraightAhead({0,0},{70,0},{70,80}));
assert(!ERR_ConvoyColumnRules::StraightAhead({0,0},{70,0},{0,0}));
assert(!ERR_ConvoyColumnRules::StraightAhead({0,0},{70,0},{70,0}));
{ERR_ConvoyTrail trail;vector target;assert(!trail.Target(target));trail.Record({0,0});trail.Record({0,50});trail.Record({50,50});assert(trail.Target(target)&&target.x==15&&target.z==50); // follows the bend, never a diagonal chord
trail.Record({500,500});assert(!trail.Target(target)&&trail.Points.Count()==1);for(int i=1;i<100;i++)trail.Record({500.f+i*10,500});assert(trail.Points.Count()==48);}
{ERR_ConvoyVehicle a,b,c;ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column col{nullptr,&task,{&sa,&sb,&sc}};a.entity.position={100};b.entity.position={-200};c.entity.position={-300};col.Tick(0);assert(col.m_RearWait&&!a.Waypoint&&sa.Speed==0);col.Tick(20000);assert(a.Waypoint&&sa.Speed==12);}
{ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
column.m_RouteIndex=1;a.RouteIndex=0;a.entity.position={-20};b.RouteIndex=1;b.entity.position={100};c.RouteIndex=1;c.entity.position={40};assert(column.Tick(0));assert(column.m_Leader==1);
a.InterceptActive=false;b.Released=true;assert(column.Tick(1000));assert(column.m_Leader==2&&c.RouteIndex==1&&a.RouteIndex==0&&column.m_RouteIndex==1);}
{ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;c.Released=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
column.m_RouteIndex=1;a.RouteIndex=0;a.entity.position={-20};b.RouteIndex=1;b.entity.position={100};assert(column.Tick(0));assert(column.m_Leader==1&&b.RouteIndex==1);
a.InterceptActive=false;assert(column.Tick(1000));assert(column.m_Leader==1&&b.RouteIndex==1&&a.RouteIndex==0&&column.m_RouteIndex==1&&b.Waypoint->position.x==400);}
{ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;b.Released=c.Released=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
a.RouteIndex=0;a.entity.position={-200};assert(column.Tick(0));assert(sa.InterceptDetached&&a.RouteIndex==0&&!a.Waypoint);
column.m_RouteIndex=1;a.InterceptActive=false;assert(column.Tick(1000));assert(a.RouteIndex==0&&a.Waypoint&&column.m_RouteIndex==0);}
// Two detached original escorts rejoin without changing the continuing leader's route.
for(int returnFirst=0;returnFirst<3;returnFirst++){
ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=c.InterceptActive=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
column.m_RouteIndex=1;a.entity.position={-20};b.entity.position={100};c.entity.position={-80};
assert(column.Tick(0)&&column.m_Leader==1&&sa.InterceptDetached&&sc.InterceptDetached);
assert(!a.Waypoint&&!c.Waypoint&&b.RouteIndex==1);
if(returnFirst==0)a.InterceptActive=false;
if(returnFirst==1)c.InterceptActive=false;
if(returnFirst==2)a.InterceptActive=c.InterceptActive=false;
assert(column.Tick(1000)&&column.m_Leader==1&&column.m_RouteIndex==1&&b.RouteIndex==1);
a.InterceptActive=c.InterceptActive=false;
assert(column.Tick(2000)&&column.m_Leader==1&&column.m_RouteIndex==1&&!sa.InterceptDetached&&!sc.InterceptDetached);
assert(a.RouteIndex==0&&c.RouteIndex==0); // no artificial route advancement on detour return
}
// A returning escort becomes a continuing follower before the column leader is lost.
{ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=c.InterceptActive=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
column.m_RouteIndex=1;a.entity.position={-20};b.entity.position={100};c.entity.position={40};
assert(column.Tick(0));c.InterceptActive=false;assert(column.Tick(1000));
b.Release(nullptr);a.InterceptActive=false;assert(column.Tick(2000));
assert(column.m_Leader==2&&column.m_RouteIndex==1&&c.RouteIndex==1&&a.RouteIndex==0);
}
// With no continuing vehicle, a sole returning original uses its own saved route.
{ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=c.InterceptActive=true;b.Released=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
column.m_RouteIndex=1;a.entity.position={-200};c.entity.position={-260};
assert(column.Tick(0)&&column.m_Leader==-1&&!a.Waypoint&&!c.Waypoint);
c.Release(nullptr);a.InterceptActive=false;assert(column.Tick(1000));
assert(column.m_Leader==0&&column.m_RouteIndex==0&&a.RouteIndex==0&&a.Waypoint);
}
assert(!ERR_ConvoyColumnRules::WaitForRear(160,false));assert(ERR_ConvoyColumnRules::WaitForRear(161,false));
assert(ERR_ConvoyColumnRules::WaitForRear(91,true));assert(!ERR_ConvoyColumnRules::WaitForRear(90,true));
assert(ERR_ConvoyColumnRules::TooClose(39,false));assert(!ERR_ConvoyColumnRules::TooClose(40,false));
assert(ERR_ConvoyColumnRules::TooClose(64,true));assert(!ERR_ConvoyColumnRules::TooClose(65,true));
ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;b.entity.position={-35};c.entity.position={-70};
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
assert(column.Tick(0));assert(a.RouteIndex==1&&a.Waypoint&&a.Waypoint->position.x==400);assert(!b.Waypoint&&!c.Waypoint);
a.entity.position={80};column.Tick(1000);assert(b.Waypoint&&b.Waypoint->position.x==45&&sb.Speed==30);assert(!c.Waypoint);
int additions=b.group.added;a.entity.position={110};column.Tick(2000);assert(b.group.added==additions); // stable target, no per-second churn
a.entity.position={200};column.Tick(3000);assert(column.m_RearWait&&!a.Waypoint&&b.Waypoint); // leader waits for laggard
b.entity.position={130};c.entity.position={60};column.Tick(11000);assert(!column.m_RearWait&&a.Waypoint);
assert(b.Waypoint->position.x==165); // native target follows predecessor, never remote base marker
b.SupplyHeld=true;auto supplyOrder=b.Waypoint;column.Tick(12000);assert(!a.Waypoint&&b.Waypoint==supplyOrder&&sb.LastProgress==-1);
b.SupplyHeld=false;b.combat.Held=true;column.Tick(13000);assert(b.Waypoint==supplyOrder&&sb.LastProgress==-1);b.combat.Held=false;
// Real vehicle positions determine arrival; no stale seated-group origin is consulted.
a.entity.position={400};b.entity.position={330};c.entity.position={260};column.Tick(20000);
assert(a.Arrived&&b.Arrived&&c.Arrived&&!a.Waypoint&&!b.Waypoint&&!c.Waypoint);
// Loss promotes the next surviving vehicle at the current route cursor, no replacement/restart at spawn.
column.Reset(20500);assert(column.m_Leader==0&&column.m_RouteIndex==2); // combat reset preserves route progress for a later promotion
a.Release(nullptr);b.Arrived=c.Arrived=false;column.Tick(21000);assert(column.m_Leader==1&&b.RouteIndex==1&&!b.Arrived&&b.Waypoint->position.x==400);
// Commanded stationary time alone retries and eventually releases; holds reset this clock.
ERR_ConvoyVehicle stuck;ERR_ConvoyColumnVehicle ss;
assert(column.Progress(&stuck,&ss,0));assert(column.Progress(&stuck,&ss,45000));assert(ERR_HQ::retries==1&&!stuck.Released);
column.Hold(&stuck,&ss,170000);assert(column.Progress(&stuck,&ss,180000));assert(!stuck.Released);
stuck.entity.position={6};assert(column.Progress(&stuck,&ss,300000));assert(ss.LastProgress==300000);
assert(column.Progress(&stuck,&ss,479999));assert(!column.Progress(&stuck,&ss,480000)&&stuck.Released&&ERR_HQ::stranded==1);
column.Reset(500000,true);assert(sa.Trail.Points.IsEmpty()&&sb.Trail.Points.IsEmpty()&&sc.Trail.Points.IsEmpty());assert(sa.LastProgress==-1&&sb.LastProgress==-1&&!column.m_RearWait&&column.m_Leader==-1);
// Post-combat physical order avoids sending the front truck toward a rear vehicle coming toward it.
b.entity.position={100};c.entity.position={300};b.Arrived=c.Arrived=false;column.m_RouteIndex=1;column.Reset(500001,false,true);
assert(column.m_Order[0]==2&&column.m_Order[1]==1&&column.m_Order[2]==0); // released vehicle last
assert(task.Vehicles[1]==&b&&task.Vehicles[2]==&c); // combat roles and canonical identities unchanged
column.Tick(500002);assert(column.m_Leader==2&&!c.Waypoint&&!b.Waypoint); // fresh formation never follows an old leg trail
b.entity.position={230};column.Tick(500003);assert(c.Waypoint&&c.Waypoint->position.x==400);
b.Release(nullptr);c.Release(nullptr);assert(!column.Tick(510000));
}
''')
print('PASS: production convoy spacing, physical arrival, stable follower targets, supply ownership, leader promotion and bounded stuck recovery')
q=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c'
orders=body(q,'protected void Orders(').replace('protected ','').replace('m_Task.','m_Task->').replace('vector.','vector::')
run(r'''
#include <cassert>
#include <cmath>
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
struct Items{int count;int Count(){return count;}};
struct Task{Items Vehicles{6},Teams{2};};
struct Controller{Task task;Task* m_Task=&task;float m_NextOrders=0;vector m_LastOrderContact,m_Contact;bool m_OrdersIssued=false,m_TruckWithdrawn=false;int support=0,flank=0;
void Support(int,bool,bool=false){support++;}void Flank(int,bool){flank++;}
'''+orders+r'''};
int main(){
Controller c;c.Orders(true,0);assert(c.support==6&&c.flank==2);
c.Orders(false,30000);assert(c.support==12&&c.flank==2&&c.m_NextOrders==60000);
c.m_Contact={39};c.Orders(false,60000);assert(c.support==18&&c.flank==2);
c.m_Contact={40};c.Orders(false,90000);assert(c.support==24&&c.flank==4);
c.m_OrdersIssued=false;c.Orders(true,100000);assert(c.support==30&&c.flank==6); // new engagement can issue at same location
}
''')
print('PASS: convoy combat keeps native manoeuvres for unchanged contacts and refreshes when contact moves')
probe=ROOT/'addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSupplyConvoyProbe.c'
departure=body(probe,'protected int Departure(').replace('protected ','').replace('m_CombatTask.','m_CombatTask->').replace('Vehicles[1].','Vehicles[1]->').replace('Entity.','Entity->').replace('vector.','vector::')
run(r'''
#include <cassert>
#include <cmath>
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
void Print(const char*){}
struct Entity{vector position;vector GetOrigin(){return position;}};
struct Vehicle{bool Released=false;struct Entity entity;struct Entity* Entity=&entity;};
struct Bases{int Count(){return 2;}};
struct Task{bool Stopped=false;int Legs=0;Vehicle* Vehicles[3];struct Bases Bases;};
struct Probe{Task* m_CombatTask;int m_Phase=3,m_TestLegs=0;vector m_RouteResumePosition;bool m_AllMoved=true;
'''+departure+r'''};
int main(){
Task task;Vehicle truck;task.Vehicles[1]=&truck;Probe p{&task};truck.entity.position={200};
assert(p.Departure()==0&&p.m_Phase==3); // earlier regroup displacement cannot pass
task.Legs=1;assert(p.Departure()==0&&p.m_Phase==4&&p.m_RouteResumePosition.x==200);
assert(p.Departure()==0);truck.entity.position={219};assert(p.Departure()==0);
truck.entity.position={220};assert(p.Departure()==1&&p.m_Phase==5);
truck.Released=true;assert(p.Departure()==-1);
}
''')
smoke=(ROOT/'addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c').read_text()
convoy=smoke.split('else if (m_ERRSmokeStep == 106)')[1].split('else if (m_ERRSmokeStep == 105)')[0]
assert 'Transition(ERR_Phase.DEFEAT)' in convoy and 'm_ERRSmokeStep = 1061' in convoy
assert 'damage.Kill' not in convoy and 'm_ERRSmokeStep = 9' not in convoy
print('PASS: native convoy proof excludes pre-departure movement and ends via explicit fixture reset, not prohibited player death')
boarding=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyBoardingProgress.c'
retry=body(boarding,'bool Retry(')
refresh=body(q,'protected void RefreshBoarding(').replace('protected ','').replace('foreach (','for (').replace('m_Task.','m_Task->').replace('team.','team->').replace('Vehicles[2].','Vehicles[2]->').replace('BoardingProgress.','BoardingProgress->').replace('Receipt.','Receipt->').replace('ERR_HQ.','ERR_HQ::').replace('Math.','Math::').replace('string.','string::').replace('ERR_SupplyTeam team','ERR_SupplyTeam* team')
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
struct string{template<class...T>static string Format(T...){return {};}};
namespace Math{float Round(float x){return std::round(x);}}
namespace ERR_HQ{void Event(const char*,string){}}
struct ERR_ConvoyBoardingProgress{int Seated=-1,Retries=0;float Distance=-1,LastProgress=0,NextRetry=0;
'''+retry+r'''};
struct ERR_SupplyTeam{bool Released=false,aboard=false;float BoardingRetryAt=0,distance=200;int seated=0,clears=0,adds=0;ERR_ConvoyBoardingProgress* BoardingProgress=nullptr;
bool Aboard(void*,bool){return aboard;}int BoardingCount(void*,float& d){d=distance;return seated;}void Clear(){clears++;}void Add(int){adds++;}};
struct Vehicle{void* Entity=nullptr;};struct Receipt{const char* SourceId="test";};
struct Task{std::vector<ERR_SupplyTeam*>Teams;Vehicle* Vehicles[3];struct Receipt receipt;struct Receipt* Receipt=&receipt;};
struct Tactics{Task* m_Task;int Board(void*,bool){return 1;}
'''+refresh+r'''};
int main(){
ERR_ConvoyBoardingProgress p;assert(!p.Retry(0,200,0));
assert(!p.Retry(0,194,29000));assert(!p.Retry(0,194,58000));assert(p.Retry(0,194,59000));
assert(!p.Retry(1,100,60000));assert(!p.Retry(1,100,89999));assert(p.Retry(1,100,90000));
assert(p.Retry(1,100,120000));assert(!p.Retry(1,100,150000)&&p.Retries==3); // bounded even if no progress
Task task;Vehicle truck;task.Vehicles[2]=&truck;ERR_SupplyTeam team,finished,released;finished.aboard=true;released.Released=true;
task.Teams={&team,&finished,&released};Tactics t{&task};t.RefreshBoarding(0);assert(team.BoardingProgress&&!finished.BoardingProgress&&!released.BoardingProgress);
t.RefreshBoarding(30000);assert(team.clears==1&&team.adds==0&&team.BoardingRetryAt==31000);
t.RefreshBoarding(30999);assert(team.adds==0);t.RefreshBoarding(31000);assert(team.adds==1&&team.BoardingRetryAt==0);
team.seated=6;team.distance=10;t.RefreshBoarding(40000);assert(team.clears==1);t.RefreshBoarding(69999);assert(team.clears==1);
t.RefreshBoarding(70000);assert(team.clears==2);team.aboard=true;t.RefreshBoarding(71000);assert(team.adds==1); // do not disturb completed boarding
delete team.BoardingProgress;
}
''')
print('PASS: boarding retries only after approach/seat progress stops, capped at three, with delayed native reissue and completed-team preservation')
safe='\n'.join(body(q,s) for s in ('protected float InfantryDistance(', 'protected void ProtectInfantry('))
safe=safe.replace('protected ','').replace('foreach (','for (')
safe=safe.replace('AICarMovementComponent movement = AICarMovementComponent.Cast(vehicle.Entity.FindComponent(AICarMovementComponent));', 'AICarMovementComponent* movement = &vehicle->Entity->movement;')
for typ in ('IEntity','ERR_SupplyTeam','ERR_ConvoyVehicle'):safe=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',safe)
for obj in ('m_Task','team','member','vehicle','movement'):safe=safe.replace(obj+'.',obj+'->')
for cls in ('ERR_Actors','CompartmentAccessComponent','Math','vector'):safe=safe.replace(cls+'.',cls+'::')
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
namespace Math{float Min(float a,float b){return a<b?a:b;}}
struct AICarMovementComponent{float speed=99;void SetCruiseSpeed(float s){speed=s;}};
struct IEntity{vector position;bool fit=true,aboard=false;AICarMovementComponent movement;vector GetOrigin(){return position;}};
namespace ERR_Actors{bool CanFight(IEntity* m){return m&&m->fit;}}
namespace CompartmentAccessComponent{IEntity* GetVehicleIn(IEntity* m){return m->aboard?m:nullptr;}}
struct ERR_SupplyTeam{bool Released=false;std::vector<IEntity*>Members;};
struct ERR_ConvoyVehicle{bool Released=false;IEntity* Entity;};
struct Task{std::vector<ERR_SupplyTeam*>Teams;std::vector<ERR_ConvoyVehicle*>Vehicles;};
struct Tactics{Task* m_Task;
'''+safe+r'''};
int main(){
IEntity truck{{0}},car{{100}},foot{{30}},passenger{{0}};passenger.aboard=true;ERR_SupplyTeam team{false,{&foot,&passenger}};
ERR_ConvoyVehicle a{false,&truck},b{false,&car};Task task{{&team},{&a,&b}};Tactics t{&task};
assert(t.InfantryDistance(&truck)==30);t.ProtectInfantry();assert(truck.movement.speed==8&&car.movement.speed==35);
foot.aboard=true;t.ProtectInfantry();assert(truck.movement.speed==35&&car.movement.speed==35);
foot.aboard=false;foot.fit=false;assert(t.InfantryDistance(&truck)>100000);foot.fit=true;team.Released=true;assert(t.InfantryDistance(&truck)>100000);
team.Released=false;a.Released=true;truck.movement.speed=99;t.ProtectInfantry();assert(truck.movement.speed==99); // never retake a released vehicle
}
''')
print('PASS: slow only owned vehicles near fit dismounted infantry; ignore passengers, casualties and released teams/vehicles')
restore=body(p,'static bool Restore(').replace('ERR_GameMode mode','void* mode').replace('ERR_ConvoyVehicle vehicle','ERR_ConvoyVehicle* vehicle')
restore=restore.replace('SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(vehicle.Entity.FindComponent(SCR_AIVehicleUsageComponent));','SCR_AIVehicleUsageComponent* usage = vehicle->Entity->usage;')
restore=restore.replace('SCR_AIGroupUtilityComponent utility', 'SCR_AIGroupUtilityComponent* utility')
for obj in ('vehicle','Group','utility','usage'):restore=restore.replace(obj+'.',obj+'->')
restore=restore.replace('ERR_ConvoyMountedCrew.','ERR_ConvoyMountedCrew::')
run(r'''
#include <cassert>
struct SCR_AIVehicleUsageComponent{bool pilot=true;bool CanBePiloted(){return pilot;}};
struct Entity{SCR_AIVehicleUsageComponent* usage=nullptr;};
struct SCR_AIGroupUtilityComponent{void* m_VehicleMgr=this;bool known=false;int added=0;bool IsUsableVehicle(Entity*){return known;}void AddUsableVehicle(SCR_AIVehicleUsageComponent*){known=true;added++;}};
struct Group{SCR_AIGroupUtilityComponent* utility=nullptr;SCR_AIGroupUtilityComponent* GetGroupUtilityComponent(){return utility;}};
struct ERR_ConvoyVehicle{bool Released=false,valid=true;struct Group group;struct Group* Group=&group;struct Entity entity;struct Entity* Entity=&entity;void* Driver=this;bool CrewValid(void*){return valid;}};
namespace ERR_ConvoyMountedCrew{bool healthyOwned=true;bool Retain(void*,Entity*){return healthyOwned;}}
'''+restore+r'''
int main(){
int mode;ERR_ConvoyVehicle car;SCR_AIGroupUtilityComponent utility;SCR_AIVehicleUsageComponent usage;car.group.utility=&utility;car.entity.usage=&usage;
assert(!Restore(nullptr,&car)&&!Restore(&mode,nullptr));car.Released=true;assert(!Restore(&mode,&car));car.Released=false;
car.valid=false;assert(!Restore(&mode,&car));car.valid=true;ERR_ConvoyMountedCrew::healthyOwned=false;assert(!Restore(&mode,&car));ERR_ConvoyMountedCrew::healthyOwned=true;
car.group.utility=nullptr;assert(!Restore(&mode,&car));car.group.utility=&utility;utility.m_VehicleMgr=nullptr;assert(!Restore(&mode,&car));utility.m_VehicleMgr=&utility;
car.entity.usage=nullptr;assert(!Restore(&mode,&car));car.entity.usage=&usage;usage.pilot=false;assert(!Restore(&mode,&car));usage.pilot=true;
assert(Restore(&mode,&car)&&utility.known&&utility.added==1);assert(!Restore(&mode,&car)&&utility.added==1);
}
''')
print('PASS: restore missing native driving registration only for healthy owned seated crews; preserve existing association and refuse invalid/released vehicles')
# Execute the production support decision, with native perception/navigation
# boundaries controlled: useful guns hold, masked guns reposition, moving
# drivers keep their order, and failure never claims working cover.
support=body(q,'protected void Support(').replace('protected ', '')
for typ in ('ERR_SupplyTeam','ERR_ConvoyVehicle','AIWaypoint'):
support=support.replace(typ+' ',typ+'* ')
for name in ('orders','vehicle','movement','m_Task'):
support=support.replace(name+'.',name+'->')
support=support.replace('m_Task->Receipt.', 'm_Task->Receipt->').replace('Entity.GetOrigin()', 'Entity->GetOrigin()').replace('Orders[0].', 'Orders[0]->')
support=support.replace('vector ', 'Vec ').replace('Vector(', 'Vec(').replace('vector.DistanceXZ', 'Distance')
support=support.replace('ERR_MountedCover.', 'ERR_MountedCover::').replace('GetGame().GetWorld().GetWorldTime()', '1000')
support=support.replace('ERR_HQ.Event', 'Event').replace('string.Format', 'Format').replace('index.ToString()', 'std::to_string(index)')
support=support.replace('float rear = 180, side;', 'float rear = 180, side=0;')
run(r'''
#include <cassert>
#include <cmath>
#include <string>
#include <vector>
using string=std::string;
struct Vec{float x=0,y=0,z=0;Vec(){}Vec(float a,float b,float c):x(a),y(b),z(c){}float operator[](int i){return i==0?x:i==1?y:z;}Vec operator-(Vec b){return{x-b.x,y-b.y,z-b.z};}Vec operator+(Vec b){return{x+b.x,y+b.y,z+b.z};}Vec operator*(float n){return{x*n,y*n,z*n};}string ToString(){return "point";}};
float Distance(Vec a,Vec b){return std::hypot(a.x-b.x,a.z-b.z);}
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;bool IsEmpty(){return this->empty();}};
template<class...T>string Format(string,T...){return "event";}void Event(string,string){}
struct AIWaypoint{Vec point;Vec GetOrigin(){return point;}};
struct Entity{Vec point{0,0,-250};Vec GetOrigin(){return point;}};
struct ERR_ConvoyVehicle{struct Entity entity;struct Entity* Entity=&entity;void* Group=nullptr;};
struct ERR_SupplyTeam{Array<AIWaypoint*>Orders;int clears=0;void Clear(){Orders.clear();clears++;}void Add(AIWaypoint*p){Orders.push_back(p);}};
struct Receipt{string SourceId="convoy";};struct Task{struct Receipt receipt;struct Receipt* Receipt=&receipt;Array<ERR_ConvoyVehicle*>Vehicles;};
namespace ERR_MountedCover{bool sees=false;bool SeesSector(void*,Vec){return sees;}Vec Approach(Vec,Vec,Vec){return{0,0,1};}}
struct Tactics{Task task;Task*m_Task=&task;Array<ERR_SupplyTeam*>m_Support;Vec m_Contact,m_Forward{0,0,1};bool usable=true,position=true,spawn=true;int plans=0,moves=0,claims=0;AIWaypoint waypoint;
bool Usable(int){return usable;}bool SupportPosition(int,Vec,Vec&out){plans++;out={100,0,-180};return position;}AIWaypoint*Move(Vec p){moves++;waypoint.point=p;return spawn?&waypoint:nullptr;}void Command(AIWaypoint*){}void Claim(void*,Vec,float){claims++;}
'''+support+r'''
};
struct Case{Tactics t;ERR_ConvoyVehicle car;ERR_SupplyTeam orders;AIWaypoint old;
Case(){t.task.Vehicles=Array<ERR_ConvoyVehicle*>(6,&car);t.m_Support=Array<ERR_SupplyTeam*>(6,&orders);old.point={0,0,-500};orders.Orders={&old};ERR_MountedCover::sees=false;}
};
int main(){
{Case c;ERR_MountedCover::sees=true;c.t.Support(4,false);assert(c.orders.Orders.empty()&&c.t.plans==0&&c.t.moves==0&&c.t.claims==1);}
{Case c;c.t.Support(4,false,true);assert(c.orders.Orders[0]==&c.old&&c.t.plans==0&&c.t.moves==0);} // driver in transit
{Case c;c.old.point=c.car.entity.point;c.t.Support(4,false,true);assert(c.t.plans==1&&c.t.moves==1&&c.orders.Orders[0]==&c.t.waypoint);} // masked arrived gun retries
{Case c;c.t.position=false;c.t.Support(4,false);assert(c.orders.Orders.empty()&&c.t.moves==0&&c.t.claims==1);} // no unsafe invented position
{Case c;c.t.spawn=false;c.t.Support(4,false);assert(c.orders.Orders[0]==&c.old);} // failed allocation preserves old command
{Case c;ERR_MountedCover::sees=true;c.t.Support(1,false);assert(c.t.plans==1&&c.t.moves==1);} // ammunition truck still withdraws
{Case c;c.t.usable=false;c.t.Support(4,false);assert(c.orders.Orders.empty()&&c.t.plans==0);}
}
''')
print('PASS: actual convoy support preserves visual cover and ongoing approach, retries masked arrived positions, withholds unsafe moves and preserves ammo withdrawal')
@@ -0,0 +1,196 @@
"""Execute production formation geometry and tactical lifecycle decisions."""
import re
from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c'
geometry='\n'.join(body(p,x) for x in ['static float ManeuverBudget(', 'static bool ExtendApproach(', 'static bool AdvanceWithoutVisualCover(', 'static bool DismountRange(', 'static bool Fresh(', 'static vector Offset(', 'static bool Stage(', 'static bool Foot('])
geometry=geometry.replace('array<vector> occupied','Array<Vec> occupied').replace('foreach (vector other : occupied)','for (Vec other : occupied)')
geometry=re.sub(r'\bvector (?=\w)','Vec ',geometry).replace('vector.','Geometry::').replace('Vector(','Vec(').replace('Math.Clamp','std::clamp').replace('30 + metres / 2, 90, 240','30 + metres / 2, 90.f, 240.f')
stubs=r'''
#include <cassert>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using string=std::string;
struct Vec {float x=0,y=0,z=0;Vec(){}Vec(float a,float b,float c):x(a),y(b),z(c){}float& operator[](int n){return n==0?x:n==1?y:z;}Vec operator+(Vec b){return {x+b.x,y+b.y,z+b.z};}Vec operator-(Vec b){return {x-b.x,y-b.y,z-b.z};}Vec operator*(float n){return {x*n,y*n,z*n};}float Length(){return std::hypot(x,z);}void Normalize(){float length=Length();x/=length;z/=length;}string ToString(){return "position";}};
namespace Geometry{float DistanceXZ(Vec a,Vec b){return std::hypot(a.x-b.x,a.z-b.z);}float Dot(Vec a,Vec b){return a.x*b.x+a.y*b.y+a.z*b.z;}}
template<class T>using Array=std::vector<T>;
'''
run(stubs+geometry+r'''
int main(){
assert(DismountRange(140)&&DismountRange(450)&&!DismountRange(139)&&!DismountRange(451));
assert(ExtendApproach(1000,91000,91000));
assert(!ExtendApproach(1000,75999,91000));
assert(!ExtendApproach(1000,301000,301000));
assert(!ExtendApproach(1000,92000,91000));
assert(!ExtendApproach(92000,90000,91000));
assert(!AdvanceWithoutVisualCover(true,47000,16999));
assert(AdvanceWithoutVisualCover(true,47000,17000));
assert(!AdvanceWithoutVisualCover(false,47000,47000));
assert(ManeuverBudget(0)==90000&&ManeuverBudget(300)==180000&&ManeuverBudget(10000)==240000);
assert(Fresh(0,60000)&&!Fresh(0,60001)&&!Fresh(100,99)&&!Fresh(-1,0));
Vec c{300,0,500};
for(int a=0;a<360;a+=15){Vec f{std::sin(a*3.14159265f/180),0,std::cos(a*3.14159265f/180)};
Vec cover=Offset(c,f,0,0),left=Offset(c,f,1,0),right=Offset(c,f,2,0);
assert(Stage(cover,c,f,{}));assert(Stage(left,c,f,{cover}));assert(Stage(right,c,f,{cover,left}));
assert(!Stage(left,c,f,{left}));assert(!Stage(c,c,f,{}));assert(!Stage(c+f*300,c,f,{}));
for(int role=1;role<3;role++){assert(Foot(Offset(c,f,role,1),c,f,role));assert(Foot(Offset(c,f,role,2),c,f,role));assert(!Foot(Offset(c,f,3-role,2),c,f,role));}
}
}
''')
tick=body(p,'bool Tick(ERR_HQ hq, float now)')
for typ in ['ERR_HQ','IEntity','AIWaypoint']:
tick=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',tick)
for n in ['Radio','hq','Unit','Attack','search','coordination','m_GunTeam']:
tick=tick.replace(n+'.',n+'->')
tick=tick.replace('ERR_HQ.Get()', 'ERR_HQ::Get()').replace('vector reserved', 'Vec reserved').replace('AttackSpace().Claim', 'AttackSpace()->Claim')
tick=tick.replace('foreach (IEntity* crew : Unit->Members)','for (IEntity* crew : Unit->Members)')
tick=tick.replace('Unit->Entity.GetOrigin()','Unit->Entity->GetOrigin()')
tick=tick.replace('ERR_HunterAttackRules.', '').replace('Math.Min', 'std::min').replace('now + 30000, m_ApproachStarted + 300000','now + 30000.f, m_ApproachStarted + 300000.f')
tick=tick.replace('AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), Sector))','SpawnSearch(Sector)')
tick=tick.replace('ERR_ConvoyMountedCrew.Hold','Hold').replace('ERR_HunterCombat.Active','Active').replace('vector.DistanceXZ','Geometry::DistanceXZ').replace('null','nullptr')
run(stubs+body(p,'static bool DismountRange(')+body(p,'static bool ExtendApproach(')+body(p,'static bool AdvanceWithoutVisualCover(')+body(p,'static float ManeuverBudget(').replace('Math.Clamp','std::clamp').replace('30 + metres / 2, 90, 240','30 + metres / 2, 90.f, 240.f')+r'''
struct IEntity{Vec pos;Vec GetOrigin(){return pos;}};
struct AIWaypoint{void SetCompletionRadius(float){}};
struct Space{void Claim(void*,Vec,float){}};struct ERR_HQ{Space space;static ERR_HQ*Get(){return nullptr;}Space*AttackSpace(){return &space;}int Journal(){return 0;}};
struct RadioType{int result=0;int Advance(float,int){return result;}};
struct GroupType{bool active=false;};bool Active(GroupType*g){return g->active;}
int holds=0;void Hold(IEntity*,IEntity*){holds++;}
struct UnitType{struct GroupType*Group=nullptr;IEntity*Entity=nullptr;Array<IEntity*>Members;void*Downlink=nullptr;bool CombatHeld=false;float CombatQuietUntil=0;int clears=0;void ClearOrder(){clears++;}};
struct AttackType{Vec ReportPosition;float Started=0;bool alive=true,ready=false;bool CoverAlive(){return alive;}bool CoverReady(){return ready;}};
struct GunTeam{int ticks=0;void Tick(float){ticks++;}void Pause(){}};
struct Tactics{
GunTeam*m_GunTeam=nullptr;bool align=false,facing=false;bool TryAlign(){return align;}bool FacingSector(){return facing;}
UnitType*Unit=nullptr;AttackType*Attack=nullptr;RadioType*Radio=nullptr;int Role=1,State=0,Retries=0;float m_TravelDistance=0,Deadline=0,NextRetry=0,m_ApproachStarted=0,m_ApproachProgress=0;Vec m_ApproachPosition;bool m_UnsupportedSearch=false,Delivered=false,Stopped=false,living=true,aboard=true,moveWorks=true,boardWorks=true,searchWorks=true;int clears=0,moves=0,boards=0;Vec Stage,Approach,Sector,position;AIWaypoint search;
bool visual=false;bool VisualCover(){return visual;}bool Living(){return living;}void Stop(){Stopped=true;Radio=nullptr;Clear();if(Delivered)Unit->CombatHeld=false;}bool Move(Vec,float=20){moves++;return moveWorks;}void Clear(){clears++;}bool Board(bool){boards++;return boardWorks;}bool Aboard(bool required){return aboard==required;}Vec Position(){return position;}void VehicleUse(bool){}void Note(string){}void ObserveState(float){}void Add(AIWaypoint*){}AIWaypoint*SpawnSearch(Vec){return searchWorks?&search:nullptr;}
'''+tick+r'''
};
struct Fixture{IEntity car,crew;GroupType group;UnitType unit;AttackType attack;RadioType radio;Tactics t;ERR_HQ hq;
Fixture(){car.pos={0,0,-250};unit.Entity=&car;unit.Group=&group;unit.Members={&crew};t.Unit=&unit;t.Attack=&attack;t.Radio=&radio;t.Stage=car.pos;t.Approach={-170,0,-85};t.Sector={-105,0,35};t.position=car.pos;}
};
int main(){
{Fixture f;f.radio.result=1;f.t.Role=0;f.t.Stage={0,0,-500};f.t.visual=true;
assert(f.t.Tick(&f.hq,1000)&&f.t.State==3); // sees fight at 250m: stop, don't back away to stage
holds=0;
}
{Fixture f;assert(!f.t.Tick(&f.hq,1000));assert(!f.t.Delivered&&!f.unit.CombatHeld&&!f.unit.clears&&f.t.moves==0);f.radio.result=-1;assert(!f.t.Tick(&f.hq,2000)&&f.t.Stopped&&f.unit.clears==0);}
{Fixture f;f.radio.result=1;f.t.moveWorks=false;assert(!f.t.Tick(&f.hq,1000)&&!f.t.Delivered&&f.unit.clears==0);}
{Fixture f;f.radio.result=1;assert(f.t.Tick(&f.hq,1000));assert(f.t.State==2&&f.t.boards==1&&f.unit.clears==1&&f.unit.CombatHeld);f.t.aboard=false;assert(f.t.Tick(&f.hq,2000)&&f.t.State==3);int moves=f.t.moves;f.t.Tick(&f.hq,5000);assert(f.t.moves==moves);f.attack.ready=true;f.t.Tick(&f.hq,6000);assert(f.t.State==4&&f.t.moves==moves+1);f.t.Tick(&f.hq,7000);assert(f.t.moves==moves+1);f.t.position=f.t.Approach;f.t.Tick(&f.hq,8000);assert(f.t.State==5);f.t.Tick(&f.hq,68000);assert(f.t.State==6);f.t.aboard=true;assert(!f.t.Tick(&f.hq,69000)&&f.t.Stopped&&!f.unit.CombatHeld);}
{Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.t.Tick(&f.hq,2000);f.t.Tick(&f.hq,16999);assert(f.t.State==3);f.t.Tick(&f.hq,17000);assert(f.t.State==4&&f.t.moves==2);}
{Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.t.Tick(&f.hq,2000);f.attack.alive=false;f.t.Tick(&f.hq,47000);assert(f.t.State==4&&f.t.moves==2&&f.t.m_UnsupportedSearch);}
{Fixture f;f.radio.result=1;f.car.pos=f.t.position={0,0,-1000};f.t.Stage={1000,0,1000};f.t.Tick(&f.hq,1000);assert(f.t.State==1);f.car.pos={0,0,-950};f.t.Tick(&f.hq,91000);assert(!f.t.Stopped&&f.t.Deadline==121000);f.t.Tick(&f.hq,121000);assert(f.t.Stopped);}
{Fixture f;f.radio.result=1;f.car.pos=f.t.position={0,0,-1000};f.t.Stage={1000,0,1000};f.t.Tick(&f.hq,1000);f.car.pos={0,0,-950};f.t.Tick(&f.hq,301000);assert(f.t.Stopped);}
{Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.attack.ready=true;f.t.Tick(&f.hq,2000);assert(f.t.State==4);f.attack.alive=false;f.t.Tick(&f.hq,3000);assert(f.t.State==3);}
{Fixture f;f.t.Role=0;f.radio.result=1;f.t.Tick(&f.hq,1000);assert(f.t.State==3&&f.t.boards==0&&holds==1);assert(!f.t.Tick(&f.hq,300000)&&f.t.Stopped);}
{Fixture f;f.radio.result=1;f.t.Stage={1000,0,1000};f.t.Tick(&f.hq,1000);assert(f.t.State==2); } // Road passes within infantry range before exact stage.
{Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.t.Tick(&f.hq,2000);f.attack.alive=false;f.t.Tick(&f.hq,47000);assert(f.t.State==4&&f.t.m_UnsupportedSearch);f.t.Tick(&f.hq,48000);assert(f.t.State==4); } // No repeated cover-loss reset.
{Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.t.Tick(&f.hq,2000);f.attack.alive=false;f.t.moveWorks=false;f.t.Tick(&f.hq,47000);assert(f.t.State==6); } // Failed foot order still regroups.
{Fixture f;f.radio.result=1;f.t.align=true;f.t.Tick(&f.hq,1000);assert(f.t.State==8&&f.t.boards==0);f.t.Tick(&f.hq,20999);assert(f.t.State==8);f.t.Tick(&f.hq,21000);assert(f.t.State==2&&f.t.boards==1);} // Alignment never blocks beyond 20s.
{Fixture f;f.radio.result=1;f.t.align=true;f.t.Tick(&f.hq,1000);f.t.facing=true;f.t.Tick(&f.hq,2000);assert(f.t.State==2);} // Stop aligning when facing.
{Fixture f;f.radio.result=1;f.t.align=true;f.t.Tick(&f.hq,1000);f.group.active=true;f.t.Tick(&f.hq,2000);assert(f.t.State==2);} // Combat preempts alignment.
{Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.living=false;assert(!f.t.Tick(&f.hq,2000)&&f.t.Stopped);}
}
''')
print('PASS: separated rear vehicle approaches, opposite infantry sectors, radio-gated ownership, failed-order preservation, visual cover or bounded supported reconnaissance, bounded dismount/search/regroup and loss cleanup')
# Execute the ownership methods too: an interrupted flank must restore only its
# own vehicle and dispose only its own waypoints, including allocation failures.
own='\n'.join(body(p,x) for x in ['protected void Clear(', 'protected void Add(', 'protected void VehicleUse(', 'protected bool Board(', 'void Stop()'])
own=own.replace('protected ','').replace('foreach (','for (')
for typ in ['AIWaypoint','SCR_BoardingWaypoint','SCR_AIGroupUtilityComponent','SCR_AIVehicleUsageComponent','IEntity','ERR_HQ']:
own=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',own)
for name in ['Unit','utility','waypoint','m_Mode','coordination','m_GunTeam']:
own=own.replace(name+'.',name+'->')
own=own.replace('ERR_HQ.Get()', 'ERR_HQ::Get()').replace('AttackSpace().Release', 'AttackSpace()->Release')
own=own.replace('Unit->Group.','Unit->Group->').replace('Unit->Entity.FindComponent(SCR_AIVehicleUsageComponent)','Unit->Entity->usage')
own=own.replace('SCR_AIVehicleUsageComponent.Cast','UsageCast').replace('SCR_BoardingWaypoint.Cast','BoardCast')
for cls in ['ERR_ConvoyRules','ERR_SupplyConvoyTactics','ERR_ConvoyMountedCrew','SCR_EntityHelper']:
own=own.replace(cls+'.',cls+'::')
own=own.replace('Unit->Recovery.SuspendBoarding()', 'Unit->Recovery->SuspendBoarding()')
own=own.replace('SCR_AIActionBase.PRIORITY_LEVEL_PLAYER','1000').replace('null','nullptr')
run(r'''
#include <cassert>
#include <algorithm>
#include <vector>
template<class T>struct Array:std::vector<T>{void Insert(T v){this->push_back(v);}void Clear(){this->clear();}};
struct AIWaypoint{bool boarding=true;int deleted=0;};
struct SCR_BoardingWaypoint:AIWaypoint{int priority=0;bool driver=false,turret=false,passenger=false;void SetPriorityLevel(int n){priority=n;}void SetAllowance(bool a,bool b,bool c){driver=a;turret=b;passenger=c;}};
SCR_BoardingWaypoint*BoardCast(AIWaypoint*w){return w&&w->boarding?static_cast<SCR_BoardingWaypoint*>(w):nullptr;}
struct SCR_AIVehicleUsageComponent{};auto UsageCast(SCR_AIVehicleUsageComponent*u){return u;}
struct SCR_AIGroupUtilityComponent{int adds=0,removes=0;void AddUsableVehicle(SCR_AIVehicleUsageComponent*){adds++;}void RemoveUsableVehicle(SCR_AIVehicleUsageComponent*){removes++;}};
struct Group{SCR_AIGroupUtilityComponent utility;Array<AIWaypoint*>waypoints;auto GetGroupUtilityComponent(){return &utility;}void RemoveWaypoint(AIWaypoint*w){auto&i=waypoints;i.erase(std::remove(i.begin(),i.end(),w),i.end());}void AddWaypoint(AIWaypoint*w){waypoints.Insert(w);}};
struct IEntity{SCR_AIVehicleUsageComponent usageValue;SCR_AIVehicleUsageComponent*usage=&usageValue;};
struct Recovery{void SuspendBoarding(){}};
struct UnitType{struct Recovery*Recovery=nullptr;struct Group*Group=nullptr;IEntity*Entity=nullptr;Array<IEntity*>Members;bool CombatHeld=true;float NextOrder=99;};
struct Mode{bool active=true;bool Active(){return active;}};
namespace ERR_ConvoyRules{bool captured=false;bool Captured(IEntity*,Group*){return captured;}}
namespace ERR_ConvoyMountedCrew{int released=0;void Release(IEntity*){released++;}}
namespace SCR_EntityHelper{void DeleteEntityAndChildren(AIWaypoint*w){w->deleted++;}}
namespace ERR_SupplyConvoyTactics{AIWaypoint*next=nullptr;bool requested=false;AIWaypoint*Board(IEntity*,bool enter){requested=enter;return next;}}
struct Space{void Release(void*){}};struct ERR_HQ{Space space;static ERR_HQ*Get(){return nullptr;}Space*AttackSpace(){return &space;}};
struct ERR_HunterGunTeam{inline static bool usable=false;int stopped=0;ERR_HunterGunTeam(UnitType*){}bool Start(){return usable;}void Stop(){stopped++;}};
struct Tactics{ERR_HunterGunTeam*m_GunTeam=nullptr;void Note(const char*){}UnitType*Unit=nullptr;Mode*m_Mode=nullptr;int Role=0;bool m_VehicleDetached=false,Stopped=false,Delivered=false,living=true;void*Radio=nullptr;Array<AIWaypoint*>Orders;bool Living(){return living&&!Stopped;}
'''+own+r'''
};
int main(){
Group g;IEntity car,man;UnitType u;u.Group=&g;u.Entity=&car;u.Members.Insert(&man);Mode mode;Tactics t;t.Unit=&u;t.m_Mode=&mode;
AIWaypoint unrelated,old;g.AddWaypoint(&unrelated);t.Add(&old);
assert(!t.Board(false)&&t.Orders.size()==1&&old.deleted==0&&g.waypoints.size()==2);
AIWaypoint wrong;wrong.boarding=false;ERR_SupplyConvoyTactics::next=&wrong;
assert(!t.Board(false)&&wrong.deleted==1&&old.deleted==0);
SCR_BoardingWaypoint exit;ERR_SupplyConvoyTactics::next=&exit;
assert(t.Board(false)&&!ERR_SupplyConvoyTactics::requested&&old.deleted==1);
assert(exit.priority==1000&&exit.driver&&exit.turret&&exit.passenger&&unrelated.deleted==0);
t.VehicleUse(false);t.VehicleUse(false);assert(g.utility.removes==1&&t.m_VehicleDetached);
t.Delivered=true;t.Stop();assert(t.Stopped&&g.utility.adds==1&&exit.deleted==1&&unrelated.deleted==0&&g.waypoints.size()==1&&!u.CombatHeld&&u.NextOrder==0);
t.Stop();assert(g.utility.adds==1&&exit.deleted==1&&ERR_ConvoyMountedCrew::released==1);
ERR_HunterGunTeam::usable=true;Tactics split;split.Unit=&u;split.m_Mode=&mode;assert(split.Board(false)&&split.m_GunTeam);auto held=split.m_GunTeam;
assert(split.Board(false)&&split.m_GunTeam==held);split.Stop();assert(held->stopped==1&&!split.m_GunTeam);split.Stop();assert(held->stopped==1);ERR_HunterGunTeam::usable=false;
int added=g.utility.adds;
for(int scenario=0;scenario<3;scenario++){
Tactics x;x.Unit=&u;x.m_Mode=&mode;x.m_VehicleDetached=true;
ERR_ConvoyRules::captured=scenario==0;mode.active=scenario!=1;x.living=scenario!=2;
x.Stop();assert(g.utility.adds==added); // Never reclaim a captured/dead/teardown vehicle.
}
}
''')
print('PASS: production dismount/boarding allocation, all crew allowances, waypoint ownership, one-time vehicle detach/restore and capture/teardown guards')
armed=(body(p,'static bool CoverCrew(').replace('ERR_HunterVehicle unit','UnitType* unit').replace('ERR_GameMode mode','int mode')+'\n'+body(p,'bool ArmedCover()')).replace('foreach (','for (').replace('BaseCompartmentSlot seat','BaseCompartmentSlot* seat').replace('bool gunner;','bool gunner=false;')
for name in ['Unit','unit','seat','m_GunTeam']:armed=armed.replace(name+'.',name+'->')
armed=armed.replace('ECompartmentType.TURRET','1').replace('ERR_Actors.CanFight','CanFight')
armed=armed.replace('ERR_HQMountedAmmoSnapshot ammo','ERR_HQMountedAmmoSnapshot* ammo').replace('ERR_HQMountedAmmoSampler.Inspect','Inspect').replace('ammo.','ammo->')
cover=body(p,'bool CoverAlive()').replace('foreach (','for (').replace('ERR_HunterTactics team','ERR_HunterTactics* team').replace('team.','team->')
run(r'''
#include <cassert>
#include <vector>
struct Man{bool fit=true;};bool CanFight(Man*m){return m&&m->fit;}
struct BaseCompartmentSlot{Man*occupant=nullptr;int type=1;int GetType(){return type;}Man*GetOccupant(){return occupant;}};
struct Members{Man*original;bool Contains(Man*m){return m==original;}};
struct ERR_HQMountedAmmoSnapshot{bool Complete=true;int Rounds=99,Chambers=1;}ammo;auto Inspect(int){return &ammo;}
struct UnitType{int Entity=0;struct Members Members;std::vector<BaseCompartmentSlot*>Seats;bool driver=true;bool CrewValid(int){return driver;}};
struct GunTeam{bool covered=true;bool Cover(){return covered;}};
struct ERR_HunterTactics{GunTeam*m_GunTeam=nullptr;UnitType*Unit;int Role=0,State=3,m_Mode=0;bool Delivered=true,living=true;bool Living(){return living;}
'''+armed+r'''
};struct Attack{std::vector<ERR_HunterTactics*>Teams;
'''+cover+r'''
};int main(){Man gunner,outsider;BaseCompartmentSlot slot;slot.occupant=&gunner;UnitType u;u.Members.original=&gunner;u.Seats={&slot};ERR_HunterTactics t;t.Unit=&u;Attack a;a.Teams={&t};assert(a.CoverAlive());gunner.fit=false;assert(!a.CoverAlive());gunner.fit=true;slot.occupant=&outsider;assert(!a.CoverAlive());slot.occupant=&gunner;u.driver=false;assert(!a.CoverAlive());u.driver=true;t.Delivered=false;assert(!a.CoverAlive());t.Delivered=true;t.living=false;assert(!a.CoverAlive());t.living=true;ammo.Rounds=0;ammo.Chambers=0;assert(!a.CoverAlive());ammo.Chambers=1;assert(a.CoverAlive());ammo.Complete=false;assert(!a.CoverAlive());GunTeam split;t.m_GunTeam=&split;t.Role=1;t.State=4;assert(a.CoverAlive());split.covered=false;assert(!a.CoverAlive());}
''')
print('PASS: covering support requires the original conscious turret gunner, valid mounted driver, live crew and delivered assignment')
# Execute production per-vehicle approach geometry: opposite arrivals stay on
# their own sides instead of sharing the cover vehicle's staging axis.
approach=body(p,'static vector Approach(').replace('vector ', 'Vec ')
run(stubs+approach+r'''
int main(){
Vec contact{100,0,100},fallback{0,0,1};
for(Vec origin : {Vec{100,0,-900},Vec{100,0,1100},Vec{-900,0,100},Vec{1100,0,100}}){
Vec forward=Approach(origin,contact,fallback);
Vec stage=contact-forward*260;
assert(Geometry::Dot(stage-contact,origin-contact)>0);
assert(Geometry::DistanceXZ(origin,stage)<Geometry::DistanceXZ(origin,contact));
assert(std::abs(forward.Length()-1)<0.0001);
}
assert(Approach(contact,contact,fallback).z==1);
}
''')