Files
EscapistsReintroduced/tests/test_convoy_intercept_pair.py
T
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

169 lines
13 KiB
Python

"""Execute actual pair admission/apply plus the complete existing vehicle policy.
Native doubles inject allocation, AddWaypoint and RemoveWaypoint boundaries.
"""
import contextlib,io,os,re,runpy
from pathlib import Path
import policy_harness as h
path=Path(os.environ.get('ERR_CONVOY_SERVICE',h.ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c'))
source=path.read_text()
assert 'bool ReserveInterceptPair(' in source and 'bool ApplyInterceptPair(' in source, 'paired service API absent (baseline feature gap)'
# Reuse existing full vehicle/native doubles without running or changing its assertions.
captured=[]; original_run=h.run
try:
h.run=captured.append
with contextlib.redirect_stdout(io.StringIO()): runpy.run_path(str(Path(os.environ.get('ERR_CONVOY_LIFECYCLE_TEST',h.ROOT/'tests/test_convoy_lifecycle.py'))))
finally: h.run=original_run
assert len(captured)==1
cpp=captured[0]
pair='\n'.join(h.body(path,s) for s in ('protected bool InterceptPairEligible(', 'bool ReserveInterceptPair(', 'bool ApplyInterceptPair('))
pair=pair.replace('protected ','').replace('foreach (','for (')
for typ in ['ERR_ConvoyTask','ERR_ConvoyInterceptOrder','ERR_ConvoyVehicle','AIWaypoint']:
pair=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',pair)
for obj in ['task','first','second','lead','partner','existing','claimed','m_Mode','firstWaypoint','secondWaypoint','Receipt']:
pair=pair.replace(obj+'.',obj+'->')
pair=re.sub(r'(Vehicles\[[^\]]+\])\.',r'\1->',pair).replace('Forces().','Forces()->')
for ns in ['ERR_ForceRules','ERR_Actors','ERR_Resources','SCR_EntityHelper','vector']:
pair=pair.replace(ns+'.',ns+'::')
pair=pair.replace('AIWaypoint.Cast(', 'static_cast<AIWaypoint*>(')
cpp=cpp.replace('bool ApplyIntercept(ERR_ConvoyTask*',pair+'\nbool ApplyIntercept(ERR_ConvoyTask*',1)
cpp=cpp[:cpp.index('int main(){')]
cpp=cpp.replace('#include <cassert>','#include <cassert>\n#include <functional>\n#include <map>')
cpp=cpp.replace('void RemoveWaypoint(AIWaypoint*){removes++;}void AddWaypoint(AIWaypoint*){adds++;}', 'std::function<void()> onRemove,onAdd;void RemoveWaypoint(AIWaypoint*){removes++;if(onRemove)onRemove();}void AddWaypoint(AIWaypoint*){adds++;if(onAdd)onAdd();}')
cpp=cpp.replace('ERR_ForceRecord* FindGroup(SCR_AIGroup*){return &record;}', 'std::map<SCR_AIGroup*,ERR_ForceRecord*> records;ERR_ForceRecord* FindGroup(SCR_AIGroup* g){return records.count(g)?records[g]:&record;}')
cpp=cpp.replace('int LogisticsToken(SCR_AIGroup*){return token;}', 'std::map<SCR_AIGroup*,int> tokens;int LogisticsToken(SCR_AIGroup* g){return tokens.count(g)?tokens[g]:token;}')
cpp=cpp.replace('int spawns=0;bool fail=false;', 'int spawns=0,failAt=0;bool fail=false;std::function<void()> onSpawn;')
cpp=cpp.replace('spawns++;return fail?nullptr:new AIWaypoint();','spawns++;if(onSpawn)onSpawn();return (fail||spawns==failAt)?nullptr:new AIWaypoint();')
cpp+=r'''
struct PairCase:InterceptCase{
Case b; ERR_ConvoyInterceptOrder second;ERR_ConvoyVehicle fillers[4];
PairCase(){
b.unit.Index=3;b.mode.ledger.record.Id=10;b.mode.ledger.record.SourceId="original-vehicle-3";
mode.ledger.records[&group]=&mode.ledger.record;mode.ledger.records[&b.group]=&b.mode.ledger.record;
mode.command.tokens[&group]=400001;mode.command.tokens[&b.group]=400002;
second=order;second.Token=400002;second.Group=&b.group;second.VehicleIndex=3;second.Position={740,0,0};
task.Vehicles={&unit,&fillers[0],&fillers[1],&b.unit,&fillers[2],&fillers[3]};
unit.Tick(&mode,&route,0);b.unit.Tick(&mode,&route,0);
}
bool ReservePair(){return service.ReserveInterceptPair(&task,&order,&second,1000);}
bool ApplyPair(){return service.ApplyInterceptPair(&task,&order,&second,2000);}
};
struct ArmoredCase:InterceptCase{
ERR_ConvoyVehicle fillers[5];ERR_InterceptVehicleFootprint footprint;
ArmoredCase(){unit.Index=4;order.VehicleIndex=4;order.Armored=true;order.ConfirmedLosses=6;footprint.root=&vehicle;order.Footprint=&footprint;
vehicle.prefab.name=ERR_ConvoyResources::VehiclePrefab(4);mode.ledger.record.Id=11;mode.ledger.record.SourceId="original-vehicle-4";
task.Vehicles={&fillers[0],&fillers[1],&fillers[2],&fillers[3],&unit,&fillers[4]};unit.Tick(&mode,&route,0);}
};
int main(){
// Baseline admits only UAZ slots: this first positive is an actual-method feature-absence failure.
{ArmoredCase f;auto route=f.unit.Waypoint;assert(f.Reserve());assert(f.unit.Waypoint==route&&!f.unit.InterceptActive);
assert(f.Apply()&&f.service.InterceptCurrent(&f.task,&f.order)&&f.unit.InterceptActive&&!f.unit.Waypoint);
f.vehicle.position=f.order.Position;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.InterceptActive&&!f.unit.InterceptWaypoint&&f.unit.RouteIndex==0);
f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint&&f.unit.RouteIndex==0);}
for(int bad=0;bad<12;bad++){ArmoredCase f;auto route=f.unit.Waypoint;
if(bad==0)f.order.Armored=false;
if(bad==1)f.order.ConfirmedLosses=5;
if(bad==2)f.order.Footprint=nullptr;
if(bad==3)f.footprint.valid=false;
if(bad==4)f.footprint.root=&f.driver;
if(bad==5)f.footprint.matches=false;
if(bad==6)f.vehicle.knownPrefab=false;
if(bad==7)f.vehicle.prefab.name="different BMP";
if(bad==8)f.task.Receipt->Consumed=false;
if(bad==9)f.mode.ledger.record.Id=9;
if(bad==10)f.seat.occupant=nullptr;
if(bad==11)f.fillers[0].SupplyHeld=true;
assert(!f.Reserve()&&!f.unit.InterceptToken&&f.unit.Waypoint==route);}
for(int bad=0;bad<5;bad++){ArmoredCase f;assert(f.Reserve());auto route=f.unit.Waypoint;ERR_InterceptVehicleFootprint replacement=f.footprint;
if(bad==0)f.order.ConfirmedLosses=7;
if(bad==1)f.order.Armored=false;
if(bad==2)f.order.Footprint=&replacement;
if(bad==3)f.footprint.matches=false;
if(bad==4)f.vehicle.prefab.name="other";
assert(!f.service.InterceptCurrent(&f.task,&f.order)&&!f.Apply()&&f.unit.Waypoint==route);
f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(!f.unit.InterceptToken);}
{ArmoredCase f;assert(f.Reserve());assert(f.Apply());f.footprint.matches=false;f.unit.Tick(&f.mode,&f.route,3000);assert(!f.unit.InterceptToken&&!f.unit.InterceptActive);}
// Failure during native prepare must not remove the original route.
{ArmoredCase f;assert(f.Reserve());auto route=f.unit.Waypoint;int removes=f.group.removes;ERR_Actors::onSpawn=[&](){f.footprint.matches=false;};
assert(!f.Apply()&&f.unit.Waypoint==route&&f.group.removes==removes&&!f.unit.InterceptActive);ERR_Actors::onSpawn={};f.service.ReleaseIntercept(&f.task,&f.order,2000);}
// Ownership revoked during route removal or installation cancels only the owned detour.
for(int edge=0;edge<2;edge++){ArmoredCase f;assert(f.Reserve());auto revoke=[&](){f.footprint.matches=false;};if(edge==0)f.group.onRemove=revoke;else f.group.onAdd=revoke;
assert(!f.Apply()&&!f.unit.InterceptActive&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);f.group.onRemove={};f.group.onAdd={};}
// A fully valid slot4 singleton still cannot become the second pair member.
{PairCase f;ERR_InterceptVehicleFootprint footprint;footprint.root=&f.b.vehicle;f.second.Footprint=&footprint;f.second.Armored=true;f.second.ConfirmedLosses=6;
f.second.VehicleIndex=4;f.b.unit.Index=4;f.b.vehicle.prefab.name=ERR_ConvoyResources::VehiclePrefab(4);
f.b.mode.ledger.record.Id=11;f.b.mode.ledger.record.SourceId="original-vehicle-4";f.task.Vehicles[3]=&f.fillers[2];f.task.Vehicles[4]=&f.b.unit;
assert(!f.ReservePair()&&!f.unit.InterceptToken&&!f.b.unit.InterceptToken);}
{PairCase f;f.order.Armored=true;assert(!f.ReservePair()&&!f.unit.InterceptToken&&!f.b.unit.InterceptToken);}
{PairCase f;f.second.Armored=true;assert(!f.ReservePair()&&!f.unit.InterceptToken&&!f.b.unit.InterceptToken);}
{PairCase f;auto a=f.unit.Waypoint;auto b=f.b.unit.Waypoint;float da=f.unit.LegDeadline,db=f.b.unit.LegDeadline;
assert(f.ReservePair());assert(f.service.InterceptCurrent(&f.task,&f.order)&&f.service.InterceptCurrent(&f.task,&f.second));
assert(f.unit.Waypoint==a&&f.b.unit.Waypoint==b&&!f.unit.InterceptActive&&!f.b.unit.InterceptActive&&f.unit.LegDeadline==da&&f.b.unit.LegDeadline==db);
assert(f.ApplyPair());assert(f.unit.InterceptActive&&f.b.unit.InterceptActive&&f.unit.InterceptWaypoint!=f.b.unit.InterceptWaypoint);
assert(f.unit.InterceptWaypoint->radius==15&&f.b.unit.InterceptWaypoint->radius==15&&f.unit.RouteIndex==0&&f.b.unit.RouteIndex==0);
assert(!f.ApplyPair());f.vehicle.position=f.order.Position;f.b.vehicle.position=f.second.Position;
f.unit.Tick(&f.mode,&f.route,3000);f.b.unit.Tick(&f.mode,&f.route,3000);
assert(f.unit.InterceptActive&&f.b.unit.InterceptActive&&!f.unit.InterceptWaypoint&&!f.b.unit.InterceptWaypoint);
f.unit.Tick(&f.mode,&f.route,121000);f.b.unit.Tick(&f.mode,&f.route,121000);
assert(!f.unit.InterceptToken&&!f.b.unit.InterceptToken&&f.unit.Waypoint&&f.b.unit.Waypoint);
}
for(int bad=0;bad<19;bad++){PairCase f;auto a=f.unit.Waypoint;auto b=f.b.unit.Waypoint;
if(bad==0)f.second.Token=f.order.Token;
if(bad==1)f.second.Group=f.order.Group;
if(bad==2)f.second.VehicleIndex=0;
if(bad==3)f.second.Position.x=739;
if(bad==4)f.second.Position.x=NAN;
if(bad==5)f.second.BeliefId++;
if(bad==6)f.second.BeliefRevision++;
if(bad==7)f.second.ReportId++;
if(bad==8)f.second.ObservedAt++;
if(bad==9)f.second.IssuedAt++;
if(bad==10)f.second.ExpiresAt++;
if(bad==11)f.second.SourceId="foreign";
if(bad==12)f.b.driver.player=true;
if(bad==13)f.b.seat.occupant=nullptr;
if(bad==14)f.fillers[0].SupplyToken=9;
if(bad==15)f.fillers[0].Combat->Held=true;
if(bad==16)f.mode.command.tokens[&f.b.group]=0;
if(bad==17)f.b.unit.Entity=&f.vehicle;
if(bad==18){f.order.ExpiresAt=121001;f.second.ExpiresAt=121001;}
assert(!f.ReservePair()&&!f.unit.InterceptToken&&!f.b.unit.InterceptToken&&f.unit.Waypoint==a&&f.b.unit.Waypoint==b);
}
// Claim two fails after claim one: spent token cannot be reused, route untouched.
{PairCase f;assert(f.b.unit.ClaimIntercept(&f.mode,&f.second));f.b.unit.ClearIntercept(&f.second);
auto a=f.unit.Waypoint;auto b=f.b.unit.Waypoint;float deadline=f.unit.LegDeadline;
assert(!f.ReservePair());assert(!f.unit.InterceptToken&&!f.b.unit.InterceptToken&&f.unit.Waypoint==a&&f.b.unit.Waypoint==b&&f.unit.LegDeadline==deadline);
}
// Global exclusion includes a different original task; never steal its claim.
{PairCase f;ERR_ConvoyTask other;ERR_ConvoyVehicle owned;owned.InterceptToken=400099;other.Vehicles={&owned};f.service.m_Tasks.Insert(&other);
assert(!f.ReservePair()&&owned.InterceptToken==400099&&!f.unit.InterceptToken);}
for(int allocation=1;allocation<=2;allocation++){PairCase f;assert(f.ReservePair());auto a=f.unit.Waypoint;auto b=f.b.unit.Waypoint;int deletes=SCR_EntityHelper::deletes,adds=f.group.adds+f.b.group.adds,removes=f.group.removes+f.b.group.removes;
ERR_Actors::failAt=ERR_Actors::spawns+allocation;assert(!f.ApplyPair());ERR_Actors::failAt=0;
assert(!f.unit.InterceptActive&&!f.b.unit.InterceptActive&&f.unit.Waypoint==a&&f.b.unit.Waypoint==b);
assert(SCR_EntityHelper::deletes==deletes+allocation-1);
assert(f.group.adds+f.b.group.adds==adds&&f.group.removes+f.b.group.removes==removes);
assert(f.unit.Entity==&f.vehicle&&f.b.unit.Entity==&f.b.vehicle&&f.unit.Driver==&f.driver&&f.b.unit.Driver==&f.b.driver);
}
// Allocation may revoke original crew. Both prepared handles are deleted before either route changes.
{PairCase f;assert(f.ReservePair());auto a=f.unit.Waypoint;auto b=f.b.unit.Waypoint;int deletes=SCR_EntityHelper::deletes;
ERR_Actors::onSpawn=[&](){f.b.driver.alive=false;};assert(!f.ApplyPair());ERR_Actors::onSpawn={};
assert(f.unit.Waypoint==a&&f.b.unit.Waypoint==b&&SCR_EntityHelper::deletes==deletes+2);}
// Immutable claimed destinations: changing both orders together is still rejected.
{PairCase f;assert(f.ReservePair());f.order.Position.x+=100;f.second.Position.x+=100;assert(!f.ApplyPair()&&!f.unit.InterceptActive&&!f.b.unit.InterceptActive);}
// Native removal and AddWaypoint may synchronously cancel the pair.
for(int boundary=0;boundary<3;boundary++){PairCase f;assert(f.ReservePair());int a=f.group.adds,b=f.b.group.adds;
auto cancel=[&](){f.service.ReleaseIntercept(&f.task,&f.order,2000);f.service.ReleaseIntercept(&f.task,&f.second,2000);};
if(boundary==0)f.group.onRemove=[&](){f.mode.command.tokens[&f.b.group]=0;};
if(boundary==1)f.group.onAdd=cancel;
if(boundary==2)f.b.group.onAdd=cancel;
assert(!f.ApplyPair());assert(!f.unit.InterceptToken&&!f.b.unit.InterceptToken&&!f.unit.InterceptActive&&!f.b.unit.InterceptActive);
assert(f.b.group.adds==b+(boundary==2));assert(f.group.adds==a+(boundary!=0));
}
// Per-member release remains exact and never steals a foreign public waypoint.
{PairCase f;assert(f.ReservePair());assert(f.ApplyPair());AIWaypoint foreign;f.b.unit.InterceptWaypoint=&foreign;
f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken&&f.b.unit.InterceptToken==400002);
f.service.ReleaseIntercept(&f.task,&f.second,3000);assert(f.b.unit.InterceptWaypoint==&foreign&&!f.b.unit.InterceptToken);}
}
'''
h.run(cpp)
print('PASS: actual typed original BMP singleton, loss/footprint/custody guards and native cancellation, UAZ-only pair admission, rollback, native allocation/revocation boundaries, per-member ownership, hold and expiry')