Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

379 lines
26 KiB
Diff

--- before/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c
+++ after/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c
@@ -114,6 +114,10 @@
protected float m_ApproachStarted;
protected float m_ApproachProgress;
protected vector m_ApproachPosition;
+ protected int m_ApproachRetries;
+ protected int m_CoverRetries;
+ protected float m_BlindSince = -1;
+ protected ref array<vector> m_RejectedStages = {};
protected bool m_UnsupportedSearch;
protected ref ERR_HunterGunTeam m_GunTeam;
protected ERR_GameMode m_Mode;
@@ -399,6 +403,48 @@
Radio = hq.Downlink(Unit.Group, "HUNTER TEAM", Unit.Source + " " + Label() + " stage=" + Stage.ToString() + " sector=" + Sector.ToString(), Attack.Message);
return Radio != null;
}
+ protected bool AlternateStage(float now)
+ {
+ // Reserve failed positions as well as the other teams. Do not repeatedly
+ // choose the same screened corner or send two vehicles to one firing point.
+ array<vector> occupied = {};
+ foreach (vector rejected : m_RejectedStages) occupied.Insert(rejected);
+ occupied.Insert(Unit.Entity.GetOrigin()); occupied.Insert(Stage);
+ foreach (ERR_HunterTactics other : Attack.Teams)
+ if (other != this && other.Living()) occupied.Insert(other.Stage);
+ vector oldStage = Stage, oldApproach = Approach, oldSector = Sector;
+ if (!Plan(occupied) || !Move(Stage, 35))
+ { Stage = oldStage; Approach = oldApproach; Sector = oldSector; return false; }
+ m_RejectedStages.Insert(oldStage);
+ State = 1; m_BlindSince = -1; m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin();
+ Deadline = Math.Min(now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance), m_ApproachStarted + 300000);
+ Note("alternate approach assigned=" + Stage.ToString());
+ return true;
+ }
+ protected bool RecoverApproach(float now)
+ {
+ if (m_ApproachRetries >= 2) { Note("approach still blocked after two recoveries; releasing assignment"); Stop(); return false; }
+ m_ApproachRetries++;
+ bool assigned;
+ if (m_ApproachRetries == 1) assigned = Move(Stage, 35);
+ else assigned = AlternateStage(now);
+ // A failed allocation or geometry search gets one bounded retry interval,
+ // not another expensive search every tick.
+ m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin();
+ Note("approach stationary for 30 seconds; recovery=" + m_ApproachRetries.ToString() + " assigned=" + assigned.ToString());
+ return true;
+ }
+ protected bool UpdateCover(float now)
+ {
+ if (VisualCover()) { m_BlindSince = -1; return true; }
+ if (m_BlindSince < 0) m_BlindSince = now;
+ if (now - m_BlindSince < 20000) return true;
+ if (m_CoverRetries >= 3) { Note("no visual cover after three alternate positions; releasing assignment"); Stop(); return false; }
+ m_CoverRetries++; m_BlindSince = now;
+ Note("cover position has no visual contact; looking for another firing angle");
+ AlternateStage(now);
+ return true;
+ }
bool Tick(ERR_HQ hq, float now)
{
if (!Living()) { Stop(); return false; }
@@ -450,6 +496,7 @@
{ 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; }
}
+ else if (now - m_ApproachProgress >= 30000 && !RecoverApproach(now)) return false;
}
if (State == 8 && (FacingSector() || ERR_HunterCombat.Active(Unit.Group) || now >= Deadline))
{
@@ -500,6 +547,7 @@
{ 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 == 3 && Role == 0 && !UpdateCover(now)) return false;
if (State == 6 && (Aboard(true) || now >= Deadline)) { Note("regroup ended; ordinary HQ travel may resume"); Stop(); return false; }
return Delivered && !Stopped;
}
--- before/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
+++ after/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
@@ -11,6 +11,10 @@
vector SearchPosition;
int SearchStep;
bool SearchPlanned;
+ // Mounted support watchdog; separate from infantry search and boarding.
+ float SupportProgressAt = -1;
+ vector SupportProgressPosition;
+ int SupportSearchStep;
ref ERR_ConvoyBoardingProgress BoardingProgress;
float BoardingRetryAt;
protected Vehicle m_WalkVehicle;
@@ -396,7 +400,7 @@
PlanFlank(i, false, team.SearchStep + 1);
}
}
- protected bool SupportPosition(int index, vector desired, out vector position)
+ protected bool SupportPosition(int index, vector desired, out vector position, bool requireLane = true)
{
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent();
@@ -404,7 +408,7 @@
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 (ERR_SupplyConvoySystem.Approach(desired, road, 70) && FreePosition(vehicle.Group, road) && vector.Dot(road - m_Contact, forward) <= -100 && utility.IsPositionAllowed(road) && (!armed || (vector.DistanceXZ(road, vehicle.Entity.GetOrigin()) >= 55 && (!requireLane || 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();
@@ -414,7 +418,8 @@
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 (vector.DistanceXZ(candidate, vehicle.Entity.GetOrigin()) < 55) continue;
+ if (requireLane && !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;
@@ -424,6 +429,9 @@
ERR_SupplyTeam orders = m_Support[index];
if (!Usable(index)) { orders.Clear(); return; }
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
+ float now = GetGame().GetWorld().GetWorldTime();
+ if (orders.SupportProgressAt < 0 || vector.DistanceXZ(vehicle.Entity.GetOrigin(), orders.SupportProgressPosition) >= 15)
+ { orders.SupportProgressAt = now; orders.SupportProgressPosition = vehicle.Entity.GetOrigin(); }
// 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;
@@ -432,12 +440,17 @@
// 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());
+ orders.Clear(); orders.SupportSearchStep = 0; orders.SupportProgressAt = now;
+ Claim(vehicle.Group, vehicle.Entity.GetOrigin(), now);
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;
+ if (retainApproach && !orders.Orders.IsEmpty() && orders.Orders[0] && vector.DistanceXZ(vehicle.Entity.GetOrigin(), orders.Orders[0].GetOrigin()) > 30)
+ {
+ if (now - orders.SupportProgressAt < 30000) return;
+ ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": approach stationary for 30 seconds; finding another route");
+ }
float rear = 180, side;
if (index == 0) side = 150;
if (index == 3) side = -150;
@@ -447,13 +460,36 @@
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]);
+ if (armed)
+ {
+ // Sample different flanks on successive checks instead of retrying the
+ // same patch indefinitely. Work remains bounded to the 30-second cadence.
+ int phase = orders.SupportSearchStep % 5;
+ if (phase == 1) side += 140;
+ if (phase == 2) side -= 140;
+ if (phase == 3) { side += 230; rear += 50; }
+ if (phase == 4) { side -= 230; rear += 50; }
+ orders.SupportSearchStep = (phase + 1) % 5;
+ }
vector desired = m_Contact - forward * rear + left * side;
vector chosen;
- if (SupportPosition(index, desired, chosen)) support = chosen;
+ bool found = SupportPosition(index, desired, chosen);
+ if (!found && armed && range > 350)
+ {
+ // A distant escort may be screened by an entire village. Move a short,
+ // checked leg toward the reported sector before requiring a firing lane.
+ // This is an approach, never a claim that the gun is covering infantry.
+ vector advance = vehicle.Entity.GetOrigin() + forward * 150;
+ if (orders.SupportSearchStep % 2 == 0) advance += left * 70;
+ else advance -= left * 70;
+ found = SupportPosition(index, advance, chosen, false);
+ if (found) ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": advancing toward reported sector without visual cover");
+ }
+ if (found) 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());
+ ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": no safe approach found; next check searches another angle from " + support.ToString());
return;
}
if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20)
@@ -461,6 +497,7 @@
AIWaypoint movement = Move(support);
if (!movement) return;
Command(movement); orders.Clear(); orders.Add(movement);
+ orders.SupportProgressAt = now; orders.SupportProgressPosition = vehicle.Entity.GetOrigin();
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();
--- before/tests/test_hunter_tactics.py
+++ after/tests/test_hunter_tactics.py
@@ -38,7 +38,7 @@
}
}
''')
-tick=body(p,'bool Tick(ERR_HQ hq, float now)')
+tick='\n'.join(body(p,x) for x in ['protected bool RecoverApproach(', 'protected bool UpdateCover(', 'bool Tick(ERR_HQ hq, float now)']).replace('protected ', '').replace('bool assigned;', 'bool assigned=false;').replace('m_ApproachRetries.ToString()', 'std::to_string(m_ApproachRetries)').replace('assigned.ToString()', 'std::to_string(assigned)')
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']:
@@ -60,6 +60,8 @@
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{
+ int m_ApproachRetries=0,m_CoverRetries=0,alternates=0;float m_BlindSince=-1;bool alternateWorks=true;
+ bool AlternateStage(float now){alternates++;if(!alternateWorks)return false;State=1;m_BlindSince=-1;m_ApproachProgress=now;Stage={500,0,-250};return true;}
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;}
@@ -69,6 +71,20 @@
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.Tick(&f.hq,1000);assert(f.t.State==3);
+ f.t.Tick(&f.hq,20999);assert(f.t.alternates==0);f.t.Tick(&f.hq,21000);assert(f.t.alternates==1&&f.t.State==1);}
+ {Fixture f;f.radio.result=1;f.t.Role=0;f.t.visual=true;f.t.Tick(&f.hq,1000);f.t.Tick(&f.hq,120000);assert(f.t.State==3&&f.t.alternates==0);}
+ {Fixture f;f.radio.result=1;f.t.Role=0;f.t.alternateWorks=false;f.t.Tick(&f.hq,1000);
+ f.t.Tick(&f.hq,21000);f.t.Tick(&f.hq,22000);assert(f.t.alternates==1);
+ f.t.Tick(&f.hq,41000);f.t.Tick(&f.hq,61000);assert(f.t.alternates==3&&!f.t.Stopped);
+ assert(!f.t.Tick(&f.hq,81000)&&f.t.Stopped);}
+ {Fixture f;f.radio.result=1;f.t.Role=0;f.t.alternateWorks=false;f.t.m_TravelDistance=1000;f.car.pos={0,0,-1100};f.t.Stage={0,0,-210};
+ f.t.Tick(&f.hq,1000);f.t.Tick(&f.hq,30999);assert(f.t.moves==1);
+ f.t.Tick(&f.hq,31000);assert(f.t.moves==2&&f.t.m_ApproachRetries==1);
+ f.t.Tick(&f.hq,61000);assert(f.t.alternates==1&&f.t.m_ApproachRetries==2);
+ assert(!f.t.Tick(&f.hq,91000)&&f.t.Stopped);}
+ {Fixture f;f.radio.result=1;f.t.Role=0;f.t.m_TravelDistance=1000;f.car.pos={0,0,-1100};f.t.Stage={0,0,-210};
+ f.t.Tick(&f.hq,1000);for(int i=1;i<=5;i++){f.car.pos.z+=20;f.t.Tick(&f.hq,1000+i*20000);}assert(f.t.m_ApproachRetries==0&&!f.t.Stopped);}
{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;
@@ -194,3 +210,35 @@
assert(Approach(contact,contact,fallback).z==1);
}
''')
+
+# Execute alternate-stage commit/rollback and reservations, not a duplicate planner.
+alternate=body(p,'protected bool AlternateStage(').replace('protected ','').replace('array<vector>', 'Array<Vec>').replace('foreach (', 'for (').replace('vector ', 'Vec ').replace('ERR_HunterTactics other', 'Tactics* other')
+for name in ['Unit','Attack','other']:
+ alternate=alternate.replace(name+'.',name+'->')
+alternate=alternate.replace('Entity.GetOrigin()', 'Entity->GetOrigin()').replace('Math.Min', 'std::min').replace('ERR_HunterAttackRules.ManeuverBudget', 'ManeuverBudget').replace('m_ApproachStarted + 300000', 'm_ApproachStarted + 300000.f')
+run(stubs.replace('template<class T>using Array=std::vector<T>;', 'template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;void Insert(T v){this->push_back(v);}};')+r'''
+struct Entity{Vec point{0,0,-600};Vec GetOrigin(){return point;}};
+struct UnitType{struct Entity entity;struct Entity*Entity=&entity;};
+struct Tactics;struct AttackType{Array<Tactics*>Teams;};
+float ManeuverBudget(float){return 240000;}
+struct Tactics{
+ UnitType unit;UnitType*Unit=&unit;AttackType attack;AttackType*Attack=&attack;
+ Vec Stage{0,0,-210},Approach{155,0,-240},Sector{105,0,35},m_ApproachPosition;
+ Array<Vec>m_RejectedStages,reservations;int State=3;float m_BlindSince=1000,m_ApproachProgress=0,m_ApproachStarted=1000,m_TravelDistance=1000,Deadline=100;
+ bool live=true,plan=true,move=true;bool Living(){return live;}
+ bool Plan(Array<Vec> occupied){reservations=occupied;Stage={200,0,-210};Approach={200,0,-85};Sector={105,0,35};return plan;}
+ bool Move(Vec,float){return move;}void Note(string){}
+'''+alternate+r'''
+};
+int main(){
+ for(int failure=0;failure<2;failure++){
+ Tactics t;t.plan=failure!=0;t.move=failure!=1;
+ assert(!t.AlternateStage(21000));assert(t.Stage.x==0&&t.Approach.x==155&&t.State==3&&t.m_RejectedStages.empty());
+ }
+ Tactics t,other,dead;dead.live=false;other.Stage={-200,0,-210};t.attack.Teams={&t,&other,&dead};t.m_RejectedStages.Insert({-100,0,-300});
+ assert(t.AlternateStage(21000));assert(t.reservations.size()==4&&t.reservations.back().x==-200);
+ assert(t.State==1&&t.Stage.x==200&&t.m_BlindSince==-1&&t.m_ApproachProgress==21000&&t.m_RejectedStages.size()==2);
+ assert(t.Deadline==261000);assert(t.AlternateStage(200000)&&t.Deadline==301000); // retries cannot extend the total approach budget
+}
+''')
+print('PASS: alternate positions exclude failed and occupied stages, roll back on failed planning/allocation, and preserve the total time budget')
--- before/tests/test_convoy_column.py
+++ after/tests/test_convoy_column.py
@@ -327,15 +327,15 @@
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_MountedCover.', 'ERR_MountedCover::').replace('GetGame().GetWorld().GetWorldTime()', 'clockNow')
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;')
+support=support.replace('float rear = 180, side;', 'float rear = 180, side=0;').replace('advance += left * 70', 'advance = advance + left * 70').replace('advance -= left * 70', 'advance = advance - left * 70')
run(r'''
#include <cassert>
#include <cmath>
#include <string>
#include <vector>
-using string=std::string;
+using string=std::string;float clockNow=1000;
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();}};
@@ -343,17 +343,26 @@
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 ERR_SupplyTeam{float SupportProgressAt=-1;Vec SupportProgressPosition;int SupportSearchStep=0;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++;}
+ bool fallback=false;Array<Vec> attempted;bool Usable(int){return usable;}bool SupportPosition(int,Vec desired,Vec&out,bool lane=true){plans++;attempted.push_back(desired);out=desired;return lane?position:fallback;}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;}
+ Case(){clockNow=1000;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;c.t.Support(4,false,true);clockNow=30999;c.t.Support(4,false,true);assert(c.t.plans==0);
+ clockNow=31000;c.t.Support(4,false,true);assert(c.t.moves==1&&c.t.plans==1);}
+ {Case c;c.t.Support(4,false,true);clockNow=21000;c.car.entity.point.x+=20;c.t.Support(4,false,true);
+ clockNow=41000;c.car.entity.point.x+=20;c.t.Support(4,false,true);assert(c.t.plans==0&&c.t.moves==0);}
+ {Case c;c.t.position=false;c.t.Support(4,false);c.t.Support(4,false);c.t.Support(4,false);
+ assert(c.t.attempted.size()==3&&c.t.attempted[0].x!=c.t.attempted[1].x&&c.t.attempted[1].x!=c.t.attempted[2].x);}
+ {Case c;c.car.entity.point={0,0,-650};c.t.position=false;c.t.fallback=true;c.t.Support(4,false);
+ assert(c.t.plans==2&&c.t.moves==1&&c.t.waypoint.point.z==-500);}
+ {Case c;c.car.entity.point={0,0,-650};c.t.position=false;c.t.Support(4,false);assert(c.t.plans==2&&c.t.moves==0);}
{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
@@ -364,3 +373,53 @@
}
''')
print('PASS: actual convoy support preserves visual cover and ongoing approach, retries masked arrived positions, withholds unsafe moves and preserves ammo withdrawal')
+
+# Exercise the actual geometry boundary for both firing positions and the
+# deliberately non-firing intermediate approach used by distant escorts.
+position=body(q,'protected bool SupportPosition(').replace('protected ','').replace('out vector ', 'Vec& ').replace('vector ', 'Vec ').replace('Vector(', 'Vec(')
+for typ in ('ERR_ConvoyVehicle','SCR_AIGroupUtilityComponent'):
+ position=position.replace(typ+' ',typ+'* ')
+position=position.replace('ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();','ERR_ConvoyPlacement placement;')
+for name in ('m_Task','vehicle','utility'):
+ position=position.replace(name+'.',name+'->')
+position=position.replace('Group.GetGroupUtilityComponent()', 'Group->GetGroupUtilityComponent()').replace('Entity.GetOrigin()', 'Entity->GetOrigin()')
+for cls in ('ERR_MountedCover','ERR_SupplyConvoySystem','ERR_Actors','ERR_ForceRules','Math','vector'):
+ position=position.replace(cls+'.',cls+'::')
+position=position.replace('candidate += Vec(', 'candidate = candidate + Vec(')
+run(r'''
+#include <cassert>
+#include <cmath>
+#include <vector>
+struct Vec{float x=0,y=0,z=0;Vec(){}Vec(float a,float b,float c):x(a),y(b),z(c){}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};}};
+namespace vector{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;}}
+struct SCR_AIGroupUtilityComponent{bool allowed=true;bool IsPositionAllowed(Vec){return allowed;}};
+struct Group{SCR_AIGroupUtilityComponent utility;auto GetGroupUtilityComponent(){return &utility;}};
+struct Entity{Vec pos{0,0,-650};Vec GetOrigin(){return pos;}};
+struct ERR_ConvoyVehicle{struct Entity entity;struct Entity*Entity=&entity;struct Group group;struct Group*Group=&group;};
+struct Task{std::vector<ERR_ConvoyVehicle*>Vehicles;};
+namespace ERR_MountedCover{bool lane=true;Vec Approach(Vec,Vec,Vec){return{0,0,1};}bool ClearLane(Entity*,Vec,Vec){return lane;}}
+namespace ERR_SupplyConvoySystem{bool road=false;bool Approach(Vec p,Vec&out,float){out=p;return road;}}
+namespace ERR_Actors{Vec Ground(Vec p){return p;}}
+namespace ERR_ForceRules{float Heading(Vec,Vec,int){return 0;}}
+namespace Math{float RandomFloat(float,float){return 0;}}
+struct ERR_ConvoyPlacement{inline static bool clear=true;bool Clear(Vec,float,float){return clear;}};
+struct Tactics{Task task;Task*m_Task=&task;Vec m_Contact,m_Forward;bool free=true;bool FreePosition(Group*,Vec){return free;}
+'''+position+r'''
+};
+int main(){
+ Tactics t;ERR_ConvoyVehicle car;t.task.Vehicles=std::vector<ERR_ConvoyVehicle*>(6,&car);Vec out;
+ for(bool road : {false,true}){
+ ERR_SupplyConvoySystem::road=road;ERR_MountedCover::lane=false;
+ assert(!t.SupportPosition(4,{0,0,-500},out)); // masked point is not firing cover
+ assert(t.SupportPosition(4,{0,0,-500},out,false)); // explicit intermediate approach
+ assert(!t.SupportPosition(4,{0,0,-650},out,false)); // cannot select the current blind hold
+ assert(!t.SupportPosition(4,{0,0,-90},out,false)); // cannot rush through the reported sector
+ t.free=false;assert(!t.SupportPosition(4,{0,0,-500},out,false));t.free=true;
+ car.group.utility.allowed=false;assert(!t.SupportPosition(4,{0,0,-500},out,false));car.group.utility.allowed=true;
+ ERR_MountedCover::lane=true;assert(t.SupportPosition(4,{0,0,-500},out));
+ }
+ ERR_SupplyConvoySystem::road=false;ERR_ConvoyPlacement::clear=false;assert(!t.SupportPosition(4,{0,0,-500},out,false));
+ assert(!t.SupportPosition(1,{0,0,-500},out)); // trucks still require roads
+}
+''')
+print('PASS: production support geometry rejects occupied, disallowed, blocked and too-close positions; only explicit intermediate approaches waive the firing-lane requirement')