--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c @@ -193,6 +193,20 @@ InterceptActive = false; InterceptOrder = null; InterceptReceipt = null; InterceptRecord = null; InterceptRoute = null; m_InterceptMembers.Clear(); m_InterceptSeats.Clear(); } + // Pair preparation owns no route state. Recheck after both native allocations. + bool CanInstallIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now) + { + if (!InterceptMatches(order) || order.Token != m_InterceptToken || InterceptActive || !InterceptCrew(mode)) return false; + return !(order.Position != m_InterceptPosition) && order.ExpiresAt == m_InterceptExpiresAt && now < m_InterceptExpiresAt; + } + // Caller transfers ownership of the prepared handle before AddWaypoint, which + // is a native boundary rather than a transactional pair operation. + void InstallIntercept(AIWaypoint detour) + { + m_InterceptRouteIndex = RouteIndex; + m_InterceptWaypoint = detour; InterceptWaypoint = detour; InterceptActive = true; + Group.AddWaypoint(detour); + } bool DeliverIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now) { if (!InterceptMatches(order) || order.Token != m_InterceptToken || InterceptActive || !InterceptCrew(mode)) return false; @@ -562,6 +576,75 @@ unit.InterceptReceipt = task.Receipt; unit.InterceptRecord = m_Mode.Forces().FindGroup(unit.Group); unit.InterceptRoute = task.Route; return true; } + 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; + 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; + if (first.BeliefId != second.BeliefId || first.BeliefRevision != second.BeliefRevision || first.ReportId != second.ReportId) return false; + if (first.ObservedAt != second.ObservedAt || first.IssuedAt != second.IssuedAt || first.ExpiresAt != second.ExpiresAt) return false; + if (!ERR_ForceRules.Finite(first.ObservedAt) || !ERR_ForceRules.Finite(first.IssuedAt) || !ERR_ForceRules.Finite(first.ExpiresAt)) return false; + if (first.ObservedAt < 0 || first.IssuedAt < first.ObservedAt || now < first.IssuedAt || now >= first.ExpiresAt || first.ExpiresAt > first.ObservedAt + 120000) return false; + if (!ERR_ForceRules.FiniteVector(first.Position) || !ERR_ForceRules.FiniteVector(second.Position)) return false; + return vector.DistanceXZ(first.Position, second.Position) >= 40; + } + bool ReserveInterceptPair(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder first, ERR_ConvoyInterceptOrder second, float now) + { + if (!InterceptPairEligible(task, first, second, now)) return false; + foreach (ERR_ConvoyTask existing : m_Tasks) + foreach (ERR_ConvoyVehicle claimed : existing.Vehicles) if (claimed && claimed.InterceptToken != 0) return false; + ERR_ConvoyVehicle lead = task.Vehicles[first.VehicleIndex]; + ERR_ConvoyVehicle partner = task.Vehicles[second.VehicleIndex]; + if (!lead.ClaimIntercept(m_Mode, first)) return false; + lead.InterceptReceipt = task.Receipt; lead.InterceptRecord = m_Mode.Forces().FindGroup(lead.Group); lead.InterceptRoute = task.Route; + if (!InterceptPairEligible(task, first, second, now) || !partner.ClaimIntercept(m_Mode, second)) + { lead.ClearIntercept(first); return false; } + partner.InterceptReceipt = task.Receipt; partner.InterceptRecord = m_Mode.Forces().FindGroup(partner.Group); partner.InterceptRoute = task.Route; + return true; + } + bool ApplyInterceptPair(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder first, ERR_ConvoyInterceptOrder second, float now) + { + if (!InterceptPairEligible(task, first, second, now) || !InterceptCurrent(task, first) || !InterceptCurrent(task, second)) return false; + ERR_ConvoyVehicle lead = task.Vehicles[first.VehicleIndex]; + ERR_ConvoyVehicle partner = task.Vehicles[second.VehicleIndex]; + if (!lead.CanInstallIntercept(m_Mode, first, now) || !partner.CanInstallIntercept(m_Mode, second, now)) return false; + AIWaypoint firstWaypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), first.Position)); + if (!firstWaypoint) return false; + AIWaypoint secondWaypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), second.Position)); + if (!secondWaypoint) { SCR_EntityHelper.DeleteEntityAndChildren(firstWaypoint); return false; } + firstWaypoint.SetCompletionRadius(15); secondWaypoint.SetCompletionRadius(15); + if (!InterceptPairEligible(task, first, second, now) || !InterceptCurrent(task, first) || !InterceptCurrent(task, second) + || task.Vehicles[first.VehicleIndex] != lead || task.Vehicles[second.VehicleIndex] != partner + || !lead.CanInstallIntercept(m_Mode, first, now) || !partner.CanInstallIntercept(m_Mode, second, now)) + { SCR_EntityHelper.DeleteEntityAndChildren(firstWaypoint); SCR_EntityHelper.DeleteEntityAndChildren(secondWaypoint); return false; } + // Route removal is also a native boundary; both prepared handles remain + // caller-owned until both exact claims have survived it. + lead.ClearOrder(); + if (InterceptPairEligible(task, first, second, now) && InterceptCurrent(task, first) && InterceptCurrent(task, second)) partner.ClearOrder(); + if (!InterceptPairEligible(task, first, second, now) || !InterceptCurrent(task, first) || !InterceptCurrent(task, second) + || task.Vehicles[first.VehicleIndex] != lead || task.Vehicles[second.VehicleIndex] != partner + || !lead.CanInstallIntercept(m_Mode, first, now) || !partner.CanInstallIntercept(m_Mode, second, now)) + { + SCR_EntityHelper.DeleteEntityAndChildren(firstWaypoint); SCR_EntityHelper.DeleteEntityAndChildren(secondWaypoint); + ReleaseIntercept(task, first, now); ReleaseIntercept(task, second, now); return false; + } + lead.InstallIntercept(firstWaypoint); + // AddWaypoint can invoke native listeners. Do not install the partner after + // cancellation/ownership loss; release only these exact owned claims. + if (!InterceptPairEligible(task, first, second, now) || !InterceptCurrent(task, first) || !lead.InterceptActive + || !InterceptCurrent(task, second) || task.Vehicles[first.VehicleIndex] != lead || task.Vehicles[second.VehicleIndex] != partner + || !partner.CanInstallIntercept(m_Mode, second, now)) + { + SCR_EntityHelper.DeleteEntityAndChildren(secondWaypoint); + ReleaseIntercept(task, first, now); ReleaseIntercept(task, second, now); return false; + } + partner.InstallIntercept(secondWaypoint); + if (InterceptPairEligible(task, first, second, now) && InterceptCurrent(task, first) && InterceptCurrent(task, second) + && task.Vehicles[first.VehicleIndex] == lead && task.Vehicles[second.VehicleIndex] == partner + && lead.InterceptActive && partner.InterceptActive) return true; + ReleaseIntercept(task, first, now); ReleaseIntercept(task, second, now); return false; + } 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; --- /dev/null +++ b/tests/test_convoy_intercept_pair.py @@ -0,0 +1,121 @@ +"""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(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(') +cpp=cpp.replace('bool ApplyIntercept(ERR_ConvoyTask*',pair+'\nbool ApplyIntercept(ERR_ConvoyTask*',1) +cpp=cpp[:cpp.index('int main(){')] +cpp=cpp.replace('#include ','#include \n#include \n#include ') +cpp=cpp.replace('void RemoveWaypoint(AIWaypoint*){removes++;}void AddWaypoint(AIWaypoint*){adds++;}', 'std::function 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 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 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 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);} +}; +int main(){ + {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 pair admission, rollback, native allocation/revocation boundaries, per-member ownership, hold and expiry')