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

220 lines
18 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
@@ -139,6 +139,9 @@
protected vector m_InterceptPosition;
protected float m_InterceptExpiresAt;
protected int m_InterceptRouteIndex;
+ protected bool m_InterceptArmored;
+ protected ref ERR_InterceptVehicleFootprint m_InterceptFootprint;
+ protected int m_InterceptConfirmedLosses;
bool Arrived;
float LegDeadline;
@@ -165,6 +168,9 @@
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;
+ if (InterceptOrder && (InterceptOrder.Armored != m_InterceptArmored || InterceptOrder.Footprint != m_InterceptFootprint
+ || InterceptOrder.ConfirmedLosses != m_InterceptConfirmedLosses)) return false;
+ if (InterceptOrder && m_InterceptArmored && (!m_InterceptFootprint || !m_InterceptFootprint.Valid() || !m_InterceptFootprint.Matches(Entity))) return false;
return true;
}
bool InterceptMatches(ERR_ConvoyInterceptOrder order)
@@ -180,6 +186,7 @@
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;
+ m_InterceptArmored = order.Armored; m_InterceptFootprint = order.Footprint; m_InterceptConfirmedLosses = order.ConfirmedLosses;
return true;
}
void ClearIntercept(ERR_ConvoyInterceptOrder order)
@@ -192,6 +199,7 @@
m_InterceptWaypoint = null; InterceptToken = 0; m_InterceptToken = 0;
InterceptActive = false; InterceptOrder = null; InterceptReceipt = null; InterceptRecord = null; InterceptRoute = null;
m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
+ m_InterceptArmored = false; m_InterceptFootprint = null; m_InterceptConfirmedLosses = 0;
}
// Pair preparation owns no route state. Recheck after both native allocations.
bool CanInstallIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now)
@@ -544,11 +552,17 @@
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.Armored)
+ {
+ if (order.VehicleIndex != 4 || order.ConfirmedLosses < 6 || !order.Footprint || !order.Footprint.Valid()) return false;
+ }
+ else 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;
+ if (order.Armored && (!unit.Entity.GetPrefabData() || unit.Entity.GetPrefabData().GetPrefabName() != ERR_ConvoyResources.VehiclePrefab(4)
+ || !order.Footprint.Matches(unit.Entity))) 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;
@@ -579,6 +593,8 @@
protected bool InterceptPairEligible(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder first, ERR_ConvoyInterceptOrder second, float now)
{
if (!first || !second || first == second || first.Group == second.Group || first.VehicleIndex == second.VehicleIndex || first.Token == second.Token) return false;
+ // Armor is a singleton alternative, never a third member or an armored pair.
+ if (first.Armored || second.Armored || first.VehicleIndex == 4 || second.VehicleIndex == 4) return false;
if (!InterceptEligible(task, first) || !InterceptEligible(task, second) || !ERR_ForceRules.Finite(now) || now < 0) return false;
if (task.Vehicles[first.VehicleIndex].Entity == task.Vehicles[second.VehicleIndex].Entity) return false;
if (first.BeliefId <= 0 || first.BeliefRevision <= 0 || first.ReportId <= 0) return false;
@@ -648,7 +664,22 @@
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);
+ if (!order.Armored) return task.Vehicles[order.VehicleIndex].DeliverIntercept(m_Mode, order, now);
+ ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
+ if (!unit.CanInstallIntercept(m_Mode, order, now)) return false;
+ AIWaypoint detour = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), order.Position));
+ if (!detour) return false;
+ detour.SetCompletionRadius(15);
+ // Bounds/custody may change during native allocation. Leave the authored
+ // route untouched until the same original claim still proves its envelope.
+ if (!InterceptCurrent(task, order) || task.Vehicles[order.VehicleIndex] != unit || !unit.CanInstallIntercept(m_Mode, order, now))
+ { SCR_EntityHelper.DeleteEntityAndChildren(detour); return false; }
+ unit.ClearOrder();
+ if (!InterceptCurrent(task, order) || task.Vehicles[order.VehicleIndex] != unit || !unit.CanInstallIntercept(m_Mode, order, now))
+ { SCR_EntityHelper.DeleteEntityAndChildren(detour); ReleaseIntercept(task, order, now); return false; }
+ unit.InstallIntercept(detour);
+ if (InterceptCurrent(task, order) && task.Vehicles[order.VehicleIndex] == unit && unit.InterceptActive) return true;
+ ReleaseIntercept(task, order, now); return false;
}
void ReleaseIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
--- a/tests/test_convoy_lifecycle.py
+++ b/tests/test_convoy_lifecycle.py
@@ -6,9 +6,9 @@
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 ','').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','ERR_ConvoyInterceptOrder','ERR_ConvoyReceipt']:
+for typ in ['Vehicle','SCR_AIGroup','IEntity','BaseCompartmentSlot','AIWaypoint','ERR_GameMode','ERR_ForceRoute','ERR_ForceRecord','AIAgent','ERR_ConvoyCombatState','ERR_ConvoyInterceptOrder','ERR_ConvoyReceipt','ERR_InterceptVehicleFootprint']:
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','order','detour','m_InterceptWaypoint','m_InterceptGroup','InterceptOrder','hq']:
+for obj in ['Waypoint','Group','Entity','mode','record','agent','member','route','Combat','order','detour','m_InterceptWaypoint','m_InterceptGroup','InterceptOrder','m_InterceptFootprint','Footprint','hq']:
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+'::')
@@ -18,15 +18,18 @@
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']:
+for typ in ['ERR_ConvoyTask','ERR_ConvoyInterceptOrder','ERR_ConvoyVehicle','ERR_ForceRecord','AIWaypoint']:
service=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',service)
-for obj in ['task','order','unit','record','other','existing','claimed','m_Mode','hq']:
+for obj in ['task','order','unit','record','other','existing','claimed','m_Mode','hq','Footprint','Entity','detour']:
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::')
+for cls in ['ERR_ConvoyResources','ERR_Actors','ERR_Resources','SCR_EntityHelper']:
+ service=service.replace(cls+'.',cls+'::')
+service=service.replace('AIWaypoint.Cast(', 'static_cast<AIWaypoint*>(').replace('GetPrefabData().','GetPrefabData()->')
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>
@@ -42,7 +45,11 @@
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 PrefabData{std::string name="escort";std::string GetPrefabName(){return name;}};
+struct Vehicle:IEntity{vector position;PrefabData prefab;bool knownPrefab=true;PrefabData* GetPrefabData(){return knownPrefab?&prefab:nullptr;}vector GetOrigin(){return position;}};
+// Footprint double controls native geometry identity, not road-selection behavior.
+struct ERR_InterceptVehicleFootprint{IEntity* root=nullptr;bool valid=true,matches=true;bool Valid(){return valid;}bool Matches(IEntity* e){return valid&&matches&&e==root;}};
+namespace ERR_ConvoyResources{std::string VehiclePrefab(int index){assert(index==4);return "{1DC01AAACFFA8B63}Prefabs/Vehicles/Tracked/BMP1/BMP1_Base.et";}}
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;};
@@ -57,7 +64,7 @@
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;}}
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;};
+struct ERR_ConvoyInterceptOrder{bool Armored=false;ERR_InterceptVehicleFootprint* Footprint=nullptr;int ConfirmedLosses=0;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;BaseCompartmentSlot seat;ERR_ConvoyVehicle unit;ERR_ForceRoute route;
--- a/tests/test_convoy_intercept_pair.py
+++ b/tests/test_convoy_intercept_pair.py
@@ -11,7 +11,7 @@
captured=[]; original_run=h.run
try:
h.run=captured.append
- with contextlib.redirect_stdout(io.StringIO()): runpy.run_path(str(h.ROOT/'tests/test_convoy_lifecycle.py'))
+ 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]
@@ -47,7 +47,54 @@
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);
@@ -118,4 +165,4 @@
}
'''
h.run(cpp)
-print('PASS: actual pair admission, rollback, native allocation/revocation boundaries, per-member ownership, hold and expiry')
+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')