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

582 lines
43 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
@@ -119,10 +119,108 @@
ref ERR_ConvoyCombatState Combat = new ERR_ConvoyCombatState();
int SupplyToken;
bool SupplyHeld;
+ int InterceptToken;
+ bool InterceptActive;
+ AIWaypoint InterceptWaypoint;
+ ref ERR_ConvoyInterceptOrder InterceptOrder;
+ ERR_ConvoyReceipt InterceptReceipt;
+ ERR_ForceRecord InterceptRecord;
+ ERR_ForceRoute InterceptRoute;
+ protected AIWaypoint m_InterceptWaypoint;
+ protected Vehicle m_InterceptEntity;
+ protected SCR_AIGroup m_InterceptGroup;
+ protected IEntity m_InterceptDriver;
+ protected ref array<IEntity> m_InterceptMembers = {};
+ protected ref array<BaseCompartmentSlot> m_InterceptSeats = {};
+ protected int m_InterceptToken;
+ protected int m_LastInterceptToken;
+ protected vector m_InterceptPosition;
+ protected float m_InterceptExpiresAt;
+ protected int m_InterceptRouteIndex;
+
bool Arrived;
float LegDeadline;
float NextReadinessNote;
+ // Pending downlink owns a claim only; normal authored travel continues.
+ bool InterceptCrew(ERR_GameMode mode)
+ {
+ if (!mode || !CrewValid(mode) || !Entity || !Group || ERR_ConvoyRules.Captured(Entity, Group)) return false;
+ if (Members.IsEmpty() || Members.Count() > 8 || Seats.Count() != Members.Count()) return false;
+ if (InterceptOrder && (Members.Count() != m_InterceptMembers.Count() || Seats.Count() != m_InterceptSeats.Count())) return false;
+ array<AIAgent> agents = {}; Group.GetAgents(agents);
+ if (agents.Count() != Members.Count()) return false;
+ for (int i = 0; i < Members.Count(); i++)
+ {
+ if (!Members[i] || !ERR_Actors.CanFight(Members[i]) || !Seats[i] || Seats[i].GetOccupant() != Members[i]) return false;
+ int attributed;
+ foreach (AIAgent agent : agents)
+ {
+ if (!agent || !Members.Contains(agent.GetControlledEntity())) return false;
+ if (agent.GetControlledEntity() == Members[i]) attributed++;
+ }
+ if (attributed != 1) return false;
+ if (InterceptOrder && (Members[i] != m_InterceptMembers[i] || Seats[i] != m_InterceptSeats[i])) return false;
+ }
+ if (InterceptOrder && (Entity != m_InterceptEntity || Group != m_InterceptGroup || Driver != m_InterceptDriver)) return false;
+ return true;
+ }
+ bool InterceptMatches(ERR_ConvoyInterceptOrder order)
+ {
+ return order && InterceptOrder == order && InterceptToken == m_InterceptToken && m_InterceptToken != 0;
+ }
+ bool ClaimIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order)
+ {
+ if (!order || order.Token <= m_LastInterceptToken || InterceptToken != 0 || InterceptOrder || InterceptWaypoint || !InterceptCrew(mode)) return false;
+ m_InterceptEntity = Entity; m_InterceptGroup = Group; m_InterceptDriver = Driver;
+ m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
+ foreach (IEntity member : Members) m_InterceptMembers.Insert(member);
+ foreach (BaseCompartmentSlot seat : Seats) m_InterceptSeats.Insert(seat);
+ m_InterceptToken = order.Token; m_LastInterceptToken = order.Token; InterceptToken = order.Token; InterceptOrder = order;
+ m_InterceptPosition = order.Position; m_InterceptExpiresAt = order.ExpiresAt;
+ return true;
+ }
+ void ClearIntercept(ERR_ConvoyInterceptOrder order)
+ {
+ if (!InterceptMatches(order)) return;
+ // Delete only the exact waypoint this owner created, never an adopted handle.
+ if (m_InterceptWaypoint && m_InterceptGroup) m_InterceptGroup.RemoveWaypoint(m_InterceptWaypoint);
+ if (m_InterceptWaypoint) SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
+ if (InterceptWaypoint == m_InterceptWaypoint) InterceptWaypoint = null;
+ m_InterceptWaypoint = null; InterceptToken = 0; m_InterceptToken = 0;
+ InterceptActive = false; InterceptOrder = null; InterceptReceipt = null; InterceptRecord = null; InterceptRoute = null;
+ m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
+ }
+ bool DeliverIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!InterceptMatches(order) || order.Token != m_InterceptToken || InterceptActive || !InterceptCrew(mode)) return false;
+ if (order.Position != m_InterceptPosition || order.ExpiresAt != m_InterceptExpiresAt || now >= m_InterceptExpiresAt) return false;
+ AIWaypoint detour = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), m_InterceptPosition));
+ if (!detour) return false;
+ detour.SetCompletionRadius(15);
+ ClearOrder(); m_InterceptRouteIndex = RouteIndex;
+ m_InterceptWaypoint = detour; InterceptWaypoint = detour; InterceptActive = true;
+ Group.AddWaypoint(detour);
+ return true;
+ }
+ // True suppresses ordinary travel only after actual radio delivery. No route
+ // index is consumed by arrival at the detour; removal leaves the seated group held.
+ bool TickIntercept(ERR_GameMode mode, float now)
+ {
+ if (!InterceptOrder) return false;
+ if (!InterceptMatches(InterceptOrder)) return InterceptActive;
+ if (Released || Arrived || now >= m_InterceptExpiresAt || !InterceptCrew(mode) || Combat.Held || SupplyHeld || SupplyToken != 0)
+ { bool delivered = InterceptActive; ClearIntercept(InterceptOrder); if (delivered) LegDeadline = now + 600000; return false; }
+ if (!InterceptActive) return false;
+ if (RouteIndex != m_InterceptRouteIndex || InterceptWaypoint != m_InterceptWaypoint)
+ { ClearIntercept(InterceptOrder); return false; }
+ if (m_InterceptWaypoint && vector.DistanceXZ(Entity.GetOrigin(), m_InterceptPosition) <= 15)
+ {
+ Group.RemoveWaypoint(m_InterceptWaypoint); SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
+ m_InterceptWaypoint = null; InterceptWaypoint = null;
+ }
+ return true;
+ }
void ClearOrder()
{
if (Waypoint && Group) Group.RemoveWaypoint(Waypoint);
@@ -132,6 +230,7 @@
void Release(ERR_GameMode mode)
{
if (Released) return;
+ ClearIntercept(InterceptOrder);
Released = true;
Combat.Held = false; SupplyToken = 0; SupplyHeld = false;
ClearOrder();
@@ -159,7 +258,7 @@
}
bool Order(ERR_ForceRoute route, float now)
{
- if (Released || Arrived || Combat.Held || SupplyHeld || !Group || !Entity || !route || RouteIndex >= route.Count()) return false;
+ if (Released || Arrived || InterceptActive || Combat.Held || SupplyHeld || !Group || !Entity || !route || RouteIndex >= route.Count()) return false;
if (Waypoint) return true;
Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), route.At(RouteIndex)));
if (!Waypoint) return false;
@@ -170,6 +269,7 @@
}
void Tick(ERR_GameMode mode, ERR_ForceRoute route, float now)
{
+ if (TickIntercept(mode, now)) return;
if (Released || Arrived) return;
if (!CrewValid(mode)) { Release(mode); return; }
if (Combat.Held || SupplyHeld) return;
@@ -417,9 +517,61 @@
if (!pending) task.Stop(m_Mode, "finite authored road route ended or all vehicle orders released");
}
}
+ protected bool InterceptEligible(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
+ {
+ if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
+ if (!m_Mode.Forces()) return false;
+ ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
+ if (!hq || hq.LogisticsToken(order.Group) != order.Token) return false;
+ if (order.Token < 400001 || order.Token > 400256 || order.SourceId != task.Receipt.SourceId) return false;
+ if (order.VehicleIndex != 0 && order.VehicleIndex != 3 && order.VehicleIndex != 5) return false;
+ if (order.VehicleIndex >= task.Vehicles.Count() || order.VehicleIndex >= task.Receipt.Records.Count()) return false;
+ ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
+ if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group) return false;
+ if (!unit.InterceptCrew(m_Mode) || !task.Route || unit.RouteIndex < 0 || unit.RouteIndex >= task.Route.Count()) return false;
+ ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
+ if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY || record.Id != task.Receipt.Records[unit.Index]) return false;
+ if (record.Group != unit.Group || record.SourceId != task.Receipt.SourceId + "-vehicle-" + unit.Index.ToString()) return false;
+ if (unit.InterceptOrder && (unit.InterceptReceipt != task.Receipt || unit.InterceptRecord != record || unit.InterceptRoute != task.Route)) return false;
+ foreach (ERR_ConvoyVehicle other : task.Vehicles)
+ if (!other || other.SupplyToken != 0 || other.SupplyHeld || other.Combat.Held) return false;
+ return true;
+ }
+ bool InterceptCurrent(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
+ {
+ return InterceptEligible(task, order) && task.Vehicles[order.VehicleIndex].InterceptToken == order.Token
+ && task.Vehicles[order.VehicleIndex].InterceptMatches(order);
+ }
+ bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!InterceptEligible(task, order) || !ERR_ForceRules.Finite(now) || now < 0) return false;
+ if (!ERR_ForceRules.Finite(order.ObservedAt) || !ERR_ForceRules.Finite(order.IssuedAt) || !ERR_ForceRules.Finite(order.ExpiresAt)) return false;
+ if (order.ObservedAt < 0 || order.IssuedAt < order.ObservedAt || now < order.IssuedAt || now >= order.ExpiresAt) return false;
+ if (order.ExpiresAt > order.ObservedAt + 120000 || !ERR_ForceRules.FiniteVector(order.Position)) return false;
+ if (order.BeliefId <= 0 || order.BeliefRevision <= 0 || order.ReportId <= 0) return false;
+ foreach (ERR_ConvoyTask existing : m_Tasks)
+ foreach (ERR_ConvoyVehicle claimed : existing.Vehicles) if (claimed && claimed.InterceptToken != 0) return false;
+ ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
+ if (!unit.ClaimIntercept(m_Mode, order)) return false;
+ unit.InterceptReceipt = task.Receipt; unit.InterceptRecord = m_Mode.Forces().FindGroup(unit.Group); unit.InterceptRoute = task.Route;
+ return true;
+ }
+ bool ApplyIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!InterceptCurrent(task, order) || !ERR_ForceRules.Finite(now) || now < order.IssuedAt || now >= order.ExpiresAt) return false;
+ return task.Vehicles[order.VehicleIndex].DeliverIntercept(m_Mode, order, now);
+ }
+ void ReleaseIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!task || !order || !m_Tasks.Contains(task) || !ERR_ForceRules.Finite(now)) return;
+ foreach (ERR_ConvoyVehicle unit : task.Vehicles)
+ if (unit && unit.InterceptMatches(order))
+ { bool delivered = unit.InterceptActive; unit.ClearIntercept(order); if (delivered) unit.LegDeadline = now + 600000; }
+ }
bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
+ foreach (ERR_ConvoyVehicle intercept : task.Vehicles) if (intercept && intercept.InterceptToken != 0) return false;
if (order.VehicleIndex < 0 || order.VehicleIndex >= task.Vehicles.Count() || order.SourceId != task.Receipt.SourceId) return false;
ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group || !unit.CrewValid(m_Mode)) return false;
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c
@@ -40,6 +40,7 @@
class ERR_ConvoyColumnVehicle
{
bool Holding;
+ bool InterceptDetached;
vector Progress;
float LastProgress = -1;
float NextRetry;
@@ -135,22 +136,38 @@
}
bool Tick(float now)
{
- int leader = -1, previous = -1; float largestGap; bool supplyHold;
+ // Rejoining escorts follow the surviving leader; their older saved route
+ // index must not rewind the rest of the formation. A sole survivor keeps its own.
+ bool rejoining;
+ for (int returning = 0; returning < m_State.Count(); returning++)
+ if (m_State[returning].InterceptDetached && !m_Task.Vehicles[returning].InterceptActive) rejoining = true;
+ if (rejoining)
+ {
+ array<int> continuingOrder = {};
+ foreach (int continuing : m_Order)
+ if (!m_State[continuing].InterceptDetached) continuingOrder.Insert(continuing);
+ foreach (int detached : m_Order)
+ if (m_State[detached].InterceptDetached) continuingOrder.Insert(detached);
+ m_Order = continuingOrder;
+ }
+ int leader = -1, previous = -1; float largestGap; bool supplyHold; bool interceptLive;
for (int slot = 0; slot < m_Order.Count(); slot++)
{
int i = m_Order[slot];
ERR_ConvoyVehicle scan = m_Task.Vehicles[i];
if (scan.Released || !scan.Entity) continue;
+ if (scan.InterceptActive) { m_State[i].InterceptDetached = true; interceptLive = true; continue; }
if (leader < 0) leader = i;
if (previous >= 0) largestGap = Math.Max(largestGap, vector.DistanceXZ(scan.Entity.GetOrigin(), m_Task.Vehicles[previous].Entity.GetOrigin()));
if (scan.SupplyHeld) supplyHold = true;
previous = i;
}
- if (leader < 0) return false;
+ if (leader < 0) return interceptLive;
if (m_Leader != leader)
{
ERR_ConvoyVehicle promoted = m_Task.Vehicles[leader]; promoted.ClearOrder(); promoted.Arrived = false;
- promoted.RouteIndex = m_RouteIndex;
+ if (m_State[leader].InterceptDetached) m_RouteIndex = promoted.RouteIndex;
+ else promoted.RouteIndex = m_RouteIndex;
m_Leader = leader;
ERR_HQ.Event("CONVOY LEADER", string.Format("%1: vehicle %2 leads surviving column", m_Task.Receipt.SourceId, leader + 1));
}
@@ -164,7 +181,9 @@
int index = m_Order[ordinal];
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
if (vehicle.Released || !vehicle.Entity) continue;
+ if (vehicle.InterceptActive) { m_State[index].LastProgress = -1; m_State[index].NextTarget = now; continue; }
ERR_ConvoyColumnVehicle state = m_State[index];
+ state.InterceptDetached = false;
if (vehicle.SupplyHeld || vehicle.Combat.Held)
{
// The external owner controls its own waypoint. Only suspend our watchdog.
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
@@ -234,7 +234,7 @@
if (!task.Column || !task.Column.Tick(now)) { task.Stop(m_Mode, "no surviving drivable convoy vehicles"); return; }
bool pending;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
- if (!vehicle.Released && (!vehicle.Arrived || vehicle.SupplyHeld || vehicle.Combat.Held)) pending = true;
+ if (!vehicle.Released && (!vehicle.Arrived || vehicle.SupplyHeld || vehicle.Combat.Held || vehicle.InterceptToken != 0)) pending = true;
if (pending) return;
if (!task.Waiting) { task.Waiting = true; task.Departure = now + Math.RandomFloat(45000, 90000); return; }
if (now < task.Departure || task.Bases.Count() < 2) return;
@@ -270,9 +270,16 @@
if (task.MineStock) task.MineStock.Tick(now, MountedStockOwner(task, 1, ERR_HQ.Cast(m_Mode.Command())));
foreach (ERR_ConvoyVehicle checked : task.Vehicles)
if (!checked.Released && !checked.CrewValid(m_Mode)) checked.Release(m_Mode);
+ foreach (ERR_ConvoyVehicle intercept : task.Vehicles) intercept.TickIntercept(m_Mode, now);
if (task.Tactics && task.Tactics.Tick(now)) continue;
Circuit(task, now);
}
+ }
+ override bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
+ if (!supply || !supply.Tactics || supply.Tactics.State() != 0) return false;
+ return super.ReserveIntercept(task, order, now);
}
override bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
@@ -369,6 +369,13 @@
}
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 = false; m_OrdersIssued = false;
if (m_Task.Column) m_Task.Column.Reset(now);
m_Task.Waiting = false;
--- a/tests/test_convoy_column.py
+++ b/tests/test_convoy_column.py
@@ -1,21 +1,25 @@
"""Run the production column controller against physical-position/native-order doubles."""
from policy_harness import ROOT, body, run
-import re
-p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'
+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 ', '')
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]','m_Task->Vehicles[previous]'):
+ 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[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;', 'float largestGap=0; bool supplyHold=false;')
+ 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('))
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('))
@@ -28,7 +32,7 @@
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
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();}};
+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);}};
struct ERR_ConvoyColumnRules{'''+rules+r'''};
struct Entity{vector position;vector GetOrigin(){return position;}};
struct Waypoint:Entity{};
@@ -36,7 +40,7 @@
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 Released=false,Arrived=false,SupplyHeld=false;
+ 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;}
@@ -48,13 +52,28 @@
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{bool Holding=false;vector Progress;float LastProgress=-1,NextRetry=0,NextTarget=0,Speed=-1;int Ahead=-1;};
+struct ERR_ConvoyColumnVehicle{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;
void Speed(ERR_ConvoyVehicle*,ERR_ConvoyColumnVehicle* state,float speed){state->Speed=speed;}
'''+methods+r'''};
int main(){
+ {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);}
+
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));
--- a/tests/test_convoy_combat.py
+++ b/tests/test_convoy_combat.py
@@ -39,7 +39,7 @@
for cls in ['ERR_ConvoyTask','ERR_ConvoyCombatOrder','ERR_ConvoyVehicle','ERR_ForceRecord','AIAgent']:
code=re.sub(r'\b'+cls+r' ([A-Za-z_]\w*)',cls+r'* \1',code)
code=code.replace('array<AIAgent>','Array<AIAgent*>').replace('foreach (','for (')
-for name in ['task','order','unit','record','agent','m_Mode']:code=code.replace(name+'.',name+'->')
+for name in ['task','order','unit','record','agent','m_Mode','intercept']:code=code.replace(name+'.',name+'->')
code=code.replace('Receipt.','Receipt->').replace('Combat.','Combat->').replace('Route.','Route->').replace('Group.','Group->').replace('Forces().','Forces()->')
for cls in ['ERR_ConvoyCombatRules','ERR_ConvoyRules','ERR_ForceKind','ERR_HQ']:code=code.replace(cls+'.',cls+'::')
code=code.replace('string.Format','Format')
@@ -52,7 +52,7 @@
struct Ledger{ERR_ForceRecord record;ERR_ForceRecord* FindGroup(Group*){return &record;}};
struct Mode{Ledger ledger;Ledger* Forces(){return &ledger;}};
struct ERR_ConvoyReceipt{bool Consumed=true;string SourceId="convoy-1";Array<int>Records{3};};
-struct ERR_ConvoyVehicle{IEntity vehicle;IEntity* Entity=&vehicle;bool SupplyHeld=false,Registered=true,Released=false,Arrived=false,valid=true,waypoint=true,failOrder=false;int Index=0,RouteIndex=2,clears=0,orders=0,releases=0;struct Group* Group;Array<IEntity*>Members;ERR_ConvoyCombatState state;ERR_ConvoyCombatState* Combat=&state;bool CrewValid(Mode*){return valid;}void ClearOrder(){clears++;waypoint=false;}bool Order(ERR_ForceRoute*,float){if(waypoint)return true;orders++;if(failOrder)return false;waypoint=true;return true;}void Release(Mode*){releases++;Released=true;Combat->Held=false;}};
+struct ERR_ConvoyVehicle{IEntity vehicle;IEntity* Entity=&vehicle;bool SupplyHeld=false,Registered=true,Released=false,Arrived=false,valid=true,waypoint=true,failOrder=false;int InterceptToken=0,Index=0,RouteIndex=2,clears=0,orders=0,releases=0;struct Group* Group;Array<IEntity*>Members;ERR_ConvoyCombatState state;ERR_ConvoyCombatState* Combat=&state;bool CrewValid(Mode*){return valid;}void ClearOrder(){clears++;waypoint=false;}bool Order(ERR_ForceRoute*,float){if(waypoint)return true;orders++;if(failOrder)return false;waypoint=true;return true;}void Release(Mode*){releases++;Released=true;Combat->Held=false;}};
struct ERR_ConvoyTask{bool Stopped=false,Ready=true;ERR_ConvoyReceipt* Receipt;ERR_ForceRoute* Route;Array<ERR_ConvoyVehicle*>Vehicles;};
namespace ERR_ConvoyRules{bool captured=false;bool Captured(IEntity*,Group*){return captured;}};
struct ERR_ConvoyCombatRules{'''+accept+r'''};
--- a/tests/test_convoy_lifecycle.py
+++ b/tests/test_convoy_lifecycle.py
@@ -1,50 +1,107 @@
"""Actual native convoy ownership/waypoint policy with small engine API doubles."""
import os,re
from pathlib import Path
-from policy_harness import ROOT,run
+from policy_harness import ROOT,run,body
path=Path(os.environ.get('ERR_CONVOY_SERVICE',str(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c')))
source=path.read_text();code=source[source.index('class ERR_ConvoyVehicle'):source.index('class ERR_ConvoyCargoCallback')]
-code=re.sub(r'//[^\n]*','',code).replace('class ERR_ConvoyVehicle','struct ERR_ConvoyVehicle').replace('ref ','')
+code=re.sub(r'//[^\n]*','',code).replace('class ERR_ConvoyVehicle','struct ERR_ConvoyVehicle').replace('ref ','').replace('protected ','')
code=code.replace('array<IEntity>','Array<IEntity*>').replace('array<BaseCompartmentSlot>','Array<BaseCompartmentSlot*>').replace('array<AIAgent>','Array<AIAgent*>')
-for typ in ['Vehicle','SCR_AIGroup','IEntity','BaseCompartmentSlot','AIWaypoint','ERR_GameMode','ERR_ForceRoute','ERR_ForceRecord','AIAgent','ERR_ConvoyCombatState']:
+for typ in ['Vehicle','SCR_AIGroup','IEntity','BaseCompartmentSlot','AIWaypoint','ERR_GameMode','ERR_ForceRoute','ERR_ForceRecord','AIAgent','ERR_ConvoyCombatState','ERR_ConvoyInterceptOrder','ERR_ConvoyReceipt']:
code=re.sub(r'\b'+typ+r' ([A-Za-z_]\w*)',typ+r'* \1',code)
-for obj in ['Waypoint','Group','Entity','mode','record','agent','member','route','Combat']:
+for obj in ['Waypoint','Group','Entity','mode','record','agent','member','route','Combat','order','detour','m_InterceptWaypoint','m_InterceptGroup','InterceptOrder']:
code=re.sub(r'\b'+obj+r'\.',obj+'->',code)
for cls in ['SCR_EntityHelper','ERR_Actors','ERR_ConvoyRules','ERR_Resources','CompartmentAccessComponent','vector']:
code=code.replace(cls+'.',cls+'::')
-code=code.replace('AIWaypoint.Cast(', 'static_cast<AIWaypoint*>(').replace('Forces().','Forces()->').replace('Command().','Command()->').replace('foreach (','for (').replace('null','nullptr').replace('\n}\n','\n};\n')
+code=code.replace('AIWaypoint.Cast(', 'static_cast<AIWaypoint*>(').replace('Seats[i].','Seats[i]->').replace('Forces().','Forces()->').replace('Command().','Command()->').replace('foreach (','for (').replace('null','nullptr').replace('\n}\n','\n};\n')
+code=code.replace('int attributed;', 'int attributed=0;')
code=re.sub(r'\b(bool|int|float|[A-Za-z_]\w*\*) ([A-Za-z_]\w*);',r'\1 \2 {};',code)
+service='\n'.join(body(path, x) for x in ('protected bool InterceptEligible(', 'bool InterceptCurrent(', 'bool ReserveIntercept(', 'bool ApplyIntercept(', 'void ReleaseIntercept('))
+service=service.replace('protected ','').replace('foreach (','for (').replace('null','nullptr')
+for typ in ['ERR_ConvoyTask','ERR_ConvoyInterceptOrder','ERR_ConvoyVehicle','ERR_ForceRecord']:
+ service=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',service)
+for obj in ['task','order','unit','record','other','existing','claimed','m_Mode','hq']:
+ service=service.replace(obj+'.',obj+'->')
+for obj in ['Receipt','Route','Combat']:
+ service=service.replace(obj+'.',obj+'->')
+service=service.replace('ERR_HQ.Cast(m_Mode->Command())','m_Mode->Command()').replace('unit->Index.ToString()', 'std::to_string(unit->Index)')
+service=service.replace('Forces().','Forces()->').replace('task->Vehicles[order->VehicleIndex].','task->Vehicles[order->VehicleIndex]->')
+service=service.replace('ERR_ForceRules.','ERR_ForceRules::').replace('ERR_ForceKind.','ERR_ForceKind::')
+service='struct ERR_ConvoyReceipt{bool Consumed=true;std::string SourceId="original";Array<int> Records={7,8,9,10,11,12};}; struct ERR_ConvoyTask{bool Stopped=false,Ready=true;ERR_ConvoyReceipt* Receipt;ERR_ForceRoute* Route;Array<ERR_ConvoyVehicle*> Vehicles;};struct Service{ERR_GameMode* m_Mode;bool current=true;Array<ERR_ConvoyTask*>m_Tasks;bool Current(){return current;}'+service+'};'
run(r'''
#include <cassert>
#include <vector>
#include <algorithm>
#include <cmath>
-struct vector{float x=0,y=0,z=0;static float DistanceXZ(vector a,vector 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;void Insert(T v){this->push_back(v);}void Clear(){this->clear();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}};
+#include <string>
+struct vector{float x=0,y=0,z=0;bool operator!=(vector b){return x!=b.x||y!=b.y||z!=b.z;}static float DistanceXZ(vector a,vector 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;int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Insert(T v){this->push_back(v);}void Clear(){this->clear();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}};
struct SCR_AIGroup;struct Vehicle;struct ERR_ConvoyCombatState{bool Held=false;};
struct IEntity{bool alive=true,player=false,awake=true;Vehicle* vehicle=nullptr;SCR_AIGroup* group=nullptr;IEntity* parent=nullptr;IEntity* GetParent(){return parent;}};
-struct AIAgent{int lod=0;void SetPermanentLOD(int value){lod=value;}};
+struct AIAgent{IEntity* actor=nullptr;IEntity* GetControlledEntity(){return actor;}int lod=0;void SetPermanentLOD(int value){lod=value;}};
struct AIWaypoint:IEntity{float radius=0;void SetCompletionRadius(float value){radius=value;}bool IsWithinCompletionRadius(SCR_AIGroup* group);};
struct SCR_AIGroup:IEntity{bool within=false;AIWaypoint* current=nullptr;int lod=0,faction=1,removes=0,adds=0;Array<AIAgent*> agents;int GetFaction(){return faction;}void SetPermanentLOD(int value){lod=value;}void GetAgents(Array<AIAgent*>& out){out=agents;}void RemoveWaypoint(AIWaypoint*){removes++;}void AddWaypoint(AIWaypoint*){adds++;}};
bool AIWaypoint::IsWithinCompletionRadius(SCR_AIGroup* group){return group->within;}
struct Vehicle:IEntity{vector position;vector GetOrigin(){return position;}};
-struct BaseCompartmentSlot{};
-struct ERR_ForceRecord{bool Pinned=true;};
+struct BaseCompartmentSlot{IEntity* occupant=nullptr;IEntity* GetOccupant(){return occupant;}};
+namespace ERR_ForceKind{const int CONVOY=3;}
+struct ERR_ForceRecord{SCR_AIGroup* Group=nullptr;std::string SourceId="original-vehicle-0";int Id=7,Kind=3;bool Physical=true,Eliminated=false,Pinned=true;};
struct Ledger{ERR_ForceRecord record;ERR_ForceRecord* FindGroup(SCR_AIGroup*){return &record;}};
-struct Command{int unregistrations=0;void UnregisterGroup(SCR_AIGroup*){unregistrations++;}};
+struct Command{int token=400001;int LogisticsToken(SCR_AIGroup*){return token;}int unregistrations=0;void UnregisterGroup(SCR_AIGroup*){unregistrations++;}};
+using ERR_HQ=Command*;struct ERR_ConvoyReceipt;
struct ERR_GameMode{struct Ledger ledger;struct Command command;int EnemyFaction(){return 1;}struct Ledger* Forces(){return &ledger;}struct Command* Command(){return &command;}};
struct ERR_ForceRoute{Array<vector> points;int Count(){return points.size();}vector At(int i){assert(i>=0&&i<Count());return points[i];}};
namespace ERR_Actors{int spawns=0;bool fail=false;bool Alive(IEntity* a){return a&&a->alive;}bool CanFight(IEntity* a){return Alive(a)&&a->awake;}bool Player(IEntity* a){return a&&a->player;}SCR_AIGroup* Group(IEntity* a){return a?a->group:nullptr;}IEntity* Spawn(int,vector){spawns++;return fail?nullptr:new AIWaypoint();}}
namespace ERR_Resources{int MoveWaypoint(){return 1;}}
namespace CompartmentAccessComponent{IEntity* GetVehicleIn(IEntity* a){return a?a->vehicle:nullptr;}}
-namespace ERR_ConvoyRules{bool PartOf(IEntity* e,IEntity* root){int depth=0;while(e&&depth++<32){if(e==root)return true;e=e->GetParent();}return false;}}
+namespace ERR_ConvoyRules{inline bool captured=false;bool Captured(Vehicle*,SCR_AIGroup*){return captured;}bool PartOf(IEntity* e,IEntity* root){int depth=0;while(e&&depth++<32){if(e==root)return true;e=e->GetParent();}return false;}}
namespace SCR_EntityHelper{int deletes=0;void DeleteEntityAndChildren(AIWaypoint* p){deletes++;delete p;}}
-''' + code + r'''
+namespace ERR_ForceRules{bool Finite(float n){return std::isfinite(n);}bool FiniteVector(vector p){return Finite(p.x)&&Finite(p.y)&&Finite(p.z);}}
+struct ERR_ConvoyInterceptOrder{int Token=400001,VehicleIndex=0,BeliefId=1,BeliefRevision=1,ReportId=1;SCR_AIGroup* Group=nullptr;std::string SourceId="original";vector Position{700,0,0};float ObservedAt=1000,IssuedAt=1000,ExpiresAt=121000;};
+''' + code + service + r'''
struct Case{
- ERR_GameMode mode;Vehicle vehicle;SCR_AIGroup group;IEntity driver;AIAgent agent;ERR_ConvoyVehicle unit;ERR_ForceRoute route;
- Case(){unit.Entity=&vehicle;unit.Group=&group;unit.Driver=&driver;unit.Members.Insert(&driver);driver.vehicle=&vehicle;driver.group=&group;group.agents.Insert(&agent);route.points={{100,0,0},{300,0,0}};}
+ ERR_GameMode mode;Vehicle vehicle;SCR_AIGroup group;IEntity driver;AIAgent agent;BaseCompartmentSlot seat;ERR_ConvoyVehicle unit;ERR_ForceRoute route;
+ Case(){mode.ledger.record.Group=&group;agent.actor=&driver;seat.occupant=&driver;unit.Seats.Insert(&seat);unit.Registered=true;unit.Entity=&vehicle;unit.Group=&group;unit.Driver=&driver;unit.Members.Insert(&driver);driver.vehicle=&vehicle;driver.group=&group;group.agents.Insert(&agent);route.points={{100,0,0},{300,0,0}};}
+};
+struct InterceptCase:Case{Service service;ERR_ConvoyReceipt receipt;ERR_ConvoyTask task;ERR_ConvoyInterceptOrder order;
+ InterceptCase(){service.m_Mode=&mode;task.Receipt=&receipt;task.Route=&route;task.Vehicles={&unit};service.m_Tasks={&task};order.Group=&group;}
+ bool Reserve(){return service.ReserveIntercept(&task,&order,1000);}bool Apply(){return service.ApplyIntercept(&task,&order,2000);}
};
int main(){
+ {InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);float deadline=f.unit.LegDeadline;auto original=f.unit.Waypoint;assert(f.Reserve());f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(f.unit.Waypoint==original&&f.unit.LegDeadline==deadline);}
+ {InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);float deadline=f.unit.LegDeadline;assert(f.Reserve());f.unit.Tick(&f.mode,&f.route,121000);assert(f.unit.LegDeadline==deadline);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());f.service.ReleaseIntercept(&f.task,&f.order,5000);assert(f.unit.LegDeadline==605000&&!f.unit.Waypoint);}
+
+ {InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);auto route=f.unit.Waypoint;assert(f.Reserve());assert(f.unit.Waypoint==route&&!f.unit.InterceptActive);
+ f.unit.Tick(&f.mode,&f.route,1500);assert(f.unit.Waypoint==route&&f.unit.RouteIndex==0);assert(f.Apply());assert(!f.unit.Waypoint&&f.unit.InterceptWaypoint&&f.unit.InterceptActive);
+ assert(!f.Apply());f.group.within=true;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.RouteIndex==0);f.vehicle.position=f.order.Position;
+ f.unit.Tick(&f.mode,&f.route,4000);assert(f.unit.InterceptActive&&!f.unit.InterceptWaypoint&&f.unit.RouteIndex==0);
+ f.unit.Tick(&f.mode,&f.route,120999);assert(!f.unit.Waypoint);f.group.within=false;f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint&&f.unit.RouteIndex==0);assert(!f.Reserve());}
+ {InterceptCase f;assert(f.Reserve());f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint);}
+ {InterceptCase f;assert(f.Reserve());f.group.within=true;f.unit.Tick(&f.mode,&f.route,1100);f.unit.Tick(&f.mode,&f.route,1200);assert(f.unit.RouteIndex==1);assert(f.Apply());assert(f.unit.RouteIndex==1);}
+ for(int bad=0;bad<14;bad++){InterceptCase f;
+ if(bad==0)f.order.Token=300001;
+ if(bad==1)f.order.VehicleIndex=1;
+ if(bad==2)f.order.ExpiresAt=121001;
+ if(bad==3)f.order.Position.x=NAN;
+ if(bad==4)f.order.IssuedAt=1001;
+ if(bad==5)f.unit.Arrived=true;
+ if(bad==6)f.unit.SupplyToken=100001;
+ if(bad==7)f.unit.SupplyHeld=true;
+ if(bad==8)f.unit.Combat->Held=true;
+ if(bad==9)f.seat.occupant=nullptr;
+ if(bad==10)f.mode.ledger.record.Id=99;
+ if(bad==11)f.agent.actor=nullptr;
+ if(bad==12)f.service.current=false;
+ if(bad==13)f.mode.command.token=0;
+ assert(!f.Reserve()&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());f.driver.alive=false;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.Released&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());f.unit.Members.Clear();f.unit.Tick(&f.mode,&f.route,3000);assert(!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
+ {InterceptCase f;assert(f.Reserve());auto foreign=f.order;foreign.Token=400002;f.service.ReleaseIntercept(&f.task,&foreign,2000);assert(f.unit.InterceptToken==400001);
+ f.unit.InterceptToken=400002;f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(f.unit.InterceptToken==400002);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());AIWaypoint foreign;f.unit.InterceptWaypoint=&foreign;f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(f.unit.InterceptWaypoint==&foreign&&!f.unit.InterceptToken);}
+ {InterceptCase f;assert(f.Reserve());f.order.Position.x++;assert(!f.Apply());f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken&&!f.unit.Waypoint);}
+ {InterceptCase f;assert(f.Reserve());ERR_Actors::fail=true;assert(!f.Apply()&&!f.unit.InterceptActive);ERR_Actors::fail=false;f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken);}
+
Case travel;travel.unit.Tick(&travel.mode,&travel.route,1000);assert(travel.group.adds==1&&travel.unit.Waypoint&&travel.unit.LegDeadline==601000);
travel.unit.Tick(&travel.mode,&travel.route,2000);assert(travel.group.adds==1&&travel.unit.LegDeadline==601000);
travel.vehicle.position={100,0,0};travel.group.within=true;travel.unit.Tick(&travel.mode,&travel.route,3000);assert(travel.unit.RouteIndex==1&&travel.group.adds==2&&travel.group.removes==1);
--- a/tests/test_supply_convoys.py
+++ b/tests/test_supply_convoys.py
@@ -42,13 +42,13 @@
struct Receipt{string SourceId="test";};
struct Combat{bool Held=false;};
struct Column{bool live=true;int resets=0;bool Tick(float){return live;}void Reset(float,bool){resets++;}};
-struct ERR_ConvoyVehicle{bool Released=false,Arrived=false,SupplyHeld=false;struct Combat combat;struct Combat* Combat=&combat;int cleared=0,RouteIndex=3;struct Entity entity;struct Entity* Entity=&entity;
+struct ERR_ConvoyVehicle{int InterceptToken=0;bool Released=false,Arrived=false,SupplyHeld=false;struct Combat combat;struct Combat* Combat=&combat;int cleared=0,RouteIndex=3;struct Entity entity;struct Entity* Entity=&entity;
void ClearOrder(){cleared++;}void Tick(void*,ERR_ForceRoute*,float){}};
struct ERR_SupplyConvoyTask{struct Column column;struct Column* Column=&column;Array<ERR_ConvoyVehicle*> Vehicles;float TravelDeadline=1000,Departure=0;bool Waiting=false,stopped=false;int LastBase=-1,Legs=0;
Array<vector> Bases={{0},{1000},{2000}};ERR_ForceRoute* Route=nullptr;struct Receipt receipt;struct Receipt* Receipt=&receipt;
void Stop(void*,string){stopped=true;}};
struct Service{void* m_Mode=nullptr;
-'''+cpp(body(D/'ERR_SupplyConvoyService.c','protected void Circuit('))+r'''};
+'''+cpp(body(Path(os.environ.get('ERR_SUPPLY_SERVICE_SOURCE',D/'ERR_SupplyConvoyService.c')),'protected void Circuit('))+r'''};
int main(){
for(int last=0;last<5;last++)for(int roll=0;roll<4;roll++){int next=ERR_SupplyConvoyRules::NextBase(5,last,roll);assert(next>=0&&next<5&&next!=last);}
assert(ERR_SupplyConvoyRules::NextBase(1,0,0)==-1);assert(ERR_SupplyConvoyRules::NextBase(3,3,0)==-1);
@@ -352,3 +352,33 @@
}
''')
print('PASS: actual supply startup reasons expose unchanged body budget/requirement, journal only failure changes, preserve retry cadence/caps and report the true final refusal')
+# Execute the entire actual combat-start method: exact detour cancellation must
+# precede ordinary waypoint/tactics ownership; foreign claims prevent takeover.
+start=cpp(body(TACTICS,'protected void Start(')).replace('intercept.','intercept->')
+start=start.replace('"0 0 1"','vector(0,0,1)').replace('Vector(','vector(')
+run(r'''
+#include <cassert>
+#include <vector>
+#include <string>
+#include <cmath>
+using string=std::string;
+struct vector{float v[3]={};vector(float x=0,float y=0,float z=0):v{x,y,z}{}float&operator[](int i){return v[i];}vector operator-(vector b){return {v[0]-b[0],v[1]-b[1],v[2]-b[2]};}float Length(){return std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);}void Normalize(){float n=Length();for(auto&x:v)x/=n;}};
+template<class T>using Array=std::vector<T>;
+struct Order{};struct Combat{bool Held=false;};struct Entity{vector GetOrigin(){return {};}};
+struct ERR_ConvoyVehicle{int InterceptToken=0,clears=0,cancels=0;bool own=true,Released=false;Order order;Order*InterceptOrder=&order;struct Combat combat;struct Combat*Combat=&combat;struct Entity entity;struct Entity*Entity=&entity;
+ void ClearIntercept(Order*p){cancels++;if(own&&p==&order)InterceptToken=0;}void ClearOrder(){clears++;}};
+struct Column{int resets=0;void Reset(float){resets++;}};struct Receipt{string SourceId="original";};
+struct Task{bool Waiting=true;Array<ERR_ConvoyVehicle*>Vehicles;struct Column column;struct Column*Column=&column;struct Receipt receipt;struct Receipt*Receipt=&receipt;};
+namespace ERR_HQ{int events=0;void Event(string,string){events++;}}
+struct Tactics{Task*m_Task;int m_State=0,m_DismountAttempts=0,m_NextDismount=0,clears=0,orders=0;bool m_TruckWithdrawn=true,m_OrdersIssued=true;vector m_Contact,m_Forward,m_Left;
+ void Clear(){clears++;}void Orders(bool,float){orders++;}
+'''+start+r'''
+};
+int main(){
+ ERR_ConvoyVehicle a,b;Task task;task.Vehicles={&a,&b};Tactics tactics{&task};
+ a.InterceptToken=400001;b.InterceptToken=400002;b.own=false;
+ tactics.Start({100,0,0},1000);assert(a.InterceptToken==0&&b.InterceptToken==400002&&tactics.clears==0&&task.column.resets==0&&tactics.orders==0&&!a.combat.Held);
+ b.own=true;tactics.Start({100,0,0},2000);assert(b.InterceptToken==0&&tactics.clears==1&&task.column.resets==1&&tactics.orders==1&&a.combat.Held&&b.combat.Held&&a.clears==1);
+}
+''')
+print('PASS: actual combat start releases only exact intercept claims before owning route/orders; foreign claim defers combat takeover')