--- 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','Array').replace('array','Array').replace('array','Array') -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(').replace('GetPrefabData().','GetPrefabData()->') service='struct ERR_ConvoyReceipt{bool Consumed=true;std::string SourceId="original";Array Records={7,8,9,10,11,12};}; struct ERR_ConvoyTask{bool Stopped=false,Ready=true;ERR_ConvoyReceipt* Receipt;ERR_ForceRoute* Route;Array Vehicles;};struct Service{ERR_GameMode* m_Mode;bool current=true;Arraym_Tasks;bool Current(){return current;}'+service+'};' run(r''' #include @@ -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 agents;int GetFaction(){return faction;}void SetPermanentLOD(int value){lod=value;}void GetAgents(Array& 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')