--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c @@ -585,6 +585,54 @@ unit.AmmoReporting.Initialize(unit.Id, GetGame().GetWorld().GetWorldTime()); AuditGroup(unit); } + + // End only an admitted convoy's dedicated travel role. Keep its original + // reporter, equipment state and finite roster; never register a replacement. + bool ReleaseConvoySurvivors(SCR_AIGroup group) + { + if (!Replication.IsServer() || m_Stopping || !m_Mode || m_Mode != ERR_GameMode.Get()) return false; + if (!m_Mode.Active() || m_Mode.Command() != this || !m_Mode.Forces() || !group) return false; + if (group.GetPlayerCount(true) > 0 || group.IsSlave() || group.GetFaction() != m_Mode.EnemyFaction()) return false; + ERR_ForceRecord record = m_Mode.Forces().FindGroup(group); + if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY) return false; + foreach (ERR_HQUnit unit : m_Units) + { + if (unit.Group != group) continue; + if (!unit.Utility || unit.ForceSourceId != record.SourceId || !unit.StatusSampler) return false; + if (!unit.StatusSampler.OwnsRoster(group) || unit.StatusSampler.InitialSourceId() != record.SourceId) return false; + int count = unit.StatusSampler.InitialStrength(); + if (count < 1 || count > 32 || count != record.InitialStrength) return false; + array agents = {}; group.GetAgents(agents); + if (agents.Count() > 32) return false; + array present = {}; + foreach (AIAgent agent : agents) + { + if (!agent || !agent.GetControlledEntity()) return false; + IEntity member = agent.GetControlledEntity(); + if (ERR_Actors.Player(member) || present.Contains(member)) return false; + present.Insert(member); + } + array originals = {}; + bool living = false; + for (int slot = 0; slot < count; slot++) + { + IEntity actor = unit.StatusSampler.InitialMember(slot); + if (!actor) continue; + originals.Insert(actor); + if (!ERR_Actors.Alive(actor)) continue; + if (!present.Contains(actor) || !unit.StatusSampler.CanAttribute(slot, actor, false)) return false; + living = true; + } + if (!living) return false; + foreach (IEntity presentMember : present) if (!originals.Contains(presentMember)) return false; + // Logistics owners retain their exact tokens until their own submitted + // inventory operations settle. Removing that quarantine here is unsafe. + if (unit.PendingConvoyOrder) unit.CancelPending(); + ReleaseRecovery(group); + return true; + } + return false; + } override void UnregisterGroup(SCR_AIGroup group) { --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c @@ -241,7 +241,11 @@ } if (Group) { - if (mode && mode.Command()) mode.Command().UnregisterGroup(Group); + ERR_HQ hq; + if (mode) hq = ERR_HQ.Cast(mode.Command()); + bool retained = false; + if (Registered && hq && !ERR_ConvoyRules.Captured(Entity, Group)) retained = hq.ReleaseConvoySurvivors(Group); + if (!retained && mode && mode.Command()) mode.Command().UnregisterGroup(Group); Group.SetPermanentLOD(-1); array agents = {}; Group.GetAgents(agents); foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1); --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c @@ -30,7 +30,11 @@ if (Released) return; Released = true; Clear(); if (!Group) return; - if (mode && mode.Command()) mode.Command().UnregisterGroup(Group); + ERR_HQ hq; + if (mode) hq = ERR_HQ.Cast(mode.Command()); + bool retained = false; + if (Registered && hq) retained = hq.ReleaseConvoySurvivors(Group); + if (!retained && mode && mode.Command()) mode.Command().UnregisterGroup(Group); ERR_ForceRecord record; if (mode && mode.Forces()) record = mode.Forces().FindGroup(Group); if (record) record.Pinned = false; --- a/tests/test_convoy_lifecycle.py +++ b/tests/test_convoy_lifecycle.py @@ -8,11 +8,12 @@ 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']: 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']: +for obj in ['Waypoint','Group','Entity','mode','record','agent','member','route','Combat','order','detour','m_InterceptWaypoint','m_InterceptGroup','InterceptOrder','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+'::') code=code.replace('AIWaypoint.Cast(', 'static_cast(').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('ERR_HQ.Cast(mode->Command())','mode->Command()').replace('ERR_HQ hq;', 'ERR_HQ hq=nullptr;') 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(')) @@ -46,7 +47,7 @@ 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 token=400001;int LogisticsToken(SCR_AIGroup*){return token;}int unregistrations=0;void UnregisterGroup(SCR_AIGroup*){unregistrations++;}}; +struct Command{bool ReleaseConvoySurvivors(SCR_AIGroup*){return false;}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 points;int Count(){return points.size();}vector At(int i){assert(i>=0&&i','Array').replace('array','Array') + s=re.sub(r'foreach \(([^:]+):',r'for (\1:',s) + for t in ('ERR_GameMode','SCR_AIGroup','ERR_ForceRecord','ERR_HQUnit','IEntity','AIAgent','ERR_HQ'): + s=re.sub(r'\b'+t+r' (\w+)',t+r'* \1',s) + for n in ('mode','m_Mode','group','Group','record','unit','actor','agent','member','hq'): + s=re.sub(r'\b'+n+r'\.',n+'->',s) + s=s.replace('StatusSampler.','StatusSampler->').replace('Forces().','Forces()->').replace('Command().','Command()->') + for n in ('Replication','ERR_GameMode','ERR_Actors','ERR_ForceKind','ERR_ConvoyRules'): + s=s.replace(n+'.',n+'::') + s=s.replace('Combat.Held','Combat.Held') + s=re.sub(r'ERR_HQ\* hq;',r'ERR_HQ* hq=nullptr;',s) + s=re.sub(r'ERR_ForceRecord\* record;',r'ERR_ForceRecord* record=nullptr;',s) + return s +hq=ai/'ERR_HQ.c' +# On the baseline the new helper is absent and never called; execute the old +# release methods unchanged so the red control fails a gameplay assertion. +retain=translate(body(hq,'bool ReleaseConvoySurvivors(')) if 'bool ReleaseConvoySurvivors(' in hq.read_text() else 'bool ReleaseConvoySurvivors(SCR_AIGroup*){return false;}' +recovery=translate(body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c','void ReleaseRecovery(')) +claims=translate(body(hq,'void ReleaseLogistics(')) +eligible=translate(body(hq,'bool Eligible(ERR_GameMode mode)')).replace('bool survivor;','bool survivor=false;') +vehicle=translate(body(ai/'ERR_ConvoyService.c','void Release(ERR_GameMode mode)')) +team=translate(body(ai/'ERR_SupplyConvoyTactics.c','void Release(ERR_GameMode mode)')) +run(r''' +#include +#include +#include +#include +templatestruct Array:std::vector{using std::vector::vector;int Count()const{return this->size();}void Insert(T x){this->push_back(x);}bool Contains(T x)const{return std::find(this->begin(),this->end(),x)!=this->end();}}; +struct SCR_AIGroup;struct ERR_HQ;struct ERR_GameMode; +struct IEntity{bool alive=true,player=false,fighting=true,mounted=false;int faction=1;SCR_AIGroup*group=nullptr;}; +struct AIAgent{IEntity*actor=nullptr;int lod=0,writes=0;IEntity*GetControlledEntity(){return actor;}void SetPermanentLOD(int n){lod=n;writes++;}}; +struct SCR_AIGroup{int faction=1,players=0,lod=0;bool slave=false;Arrayagents;int GetFaction(){return faction;}int GetPlayerCount(bool){return players;}bool IsSlave(){return slave;}void GetAgents(Array&a){a=agents;}void SetPermanentLOD(int n){lod=n;}}; +namespace Replication{bool server=true;bool IsServer(){return server;}} +namespace ERR_Actors{bool Alive(IEntity*a){return a&&a->alive;}bool Player(IEntity*a){return a&&a->player;}bool CanFight(IEntity*a){return Alive(a)&&a->fighting;}} +namespace CompartmentAccessComponent{bool GetVehicleIn(IEntity*a){return a&&a->mounted;}} +namespace ERR_ConvoyRules{bool captured=false;bool Captured(void*,SCR_AIGroup*){return captured;}} +namespace ERR_ForceKind{enum{CONVOY,GARRISON};} +struct ERR_ForceRecord{bool Physical=true,Eliminated=false,Pinned=true;int Kind=ERR_ForceKind::CONVOY,InitialStrength=2;std::string SourceId="original";}; +struct Ledger{ERR_ForceRecord record;bool present=true;ERR_ForceRecord*FindGroup(SCR_AIGroup*){return present?&record:nullptr;}}; +struct ERR_GameMode{static ERR_GameMode*current;bool active=true;ERR_HQ*command=nullptr;Ledger ledger;static ERR_GameMode*Get(){return current;}bool Active(){return active;}ERR_HQ*Command(){return command;}Ledger*Forces(){return &ledger;}int EnemyFaction(){return 1;}};ERR_GameMode*ERR_GameMode::current=nullptr; +struct Sampler{bool bound=true;std::string source="original";SCR_AIGroup*group=nullptr;Arraymembers;bool released[2]={false,false};bool OwnsRoster(SCR_AIGroup*g){return bound&&g==group;}std::string InitialSourceId(){return source;}int InitialStrength(){return members.Count();}IEntity*InitialMember(int i){return members[i];}bool CanAttribute(int slot,IEntity*a,bool observe){assert(!observe);return a&&!released[slot]&&!a->player&&a->group==group&&a->faction==1;}}; +struct ERR_HQUnit{SCR_AIGroup*Group=nullptr;void*Utility=this;std::string ForceSourceId="original";Sampler*StatusSampler=nullptr;bool ReservedForSupport=true;int LogisticsReservation=0;bool PendingConvoyOrder=false,PendingOrder=false,PendingCheckin=false,Downlink=false;int pendingClears=0;bool reports=true; + void CancelPending(){PendingConvoyOrder=PendingOrder=PendingCheckin=Downlink=false;pendingClears++;} +'''+eligible.replace('CompartmentAccessComponent.','CompartmentAccessComponent::')+r''' +}; +struct ERR_HQ{bool m_Stopping=false;ERR_GameMode*m_Mode=nullptr;Arraym_Units;int unregistrations=0,registrations=0,equipmentStops=0; + void UnregisterGroup(SCR_AIGroup*g){unregistrations++;equipmentStops++;for(auto u:m_Units)if(u->Group==g){u->Group=nullptr;u->Utility=nullptr;u->reports=false;}} +'''+recovery+claims+retain+r''' +}; +struct VehicleOwner{bool Released=false,Registered=true;void*InterceptOrder=nullptr;SCR_AIGroup*Group=nullptr;void*Entity=this;struct{bool Held=true;}Combat;int SupplyToken=101;bool SupplyHeld=true;int clears=0,intercepts=0; + void ClearIntercept(void*){intercepts++;}void ClearOrder(){clears++;} +'''+vehicle+r''' +}; +struct TeamOwner{bool Released=false,Registered=true;SCR_AIGroup*Group=nullptr;int clears=0;void Clear(){clears++;} +'''+team+r''' +}; +struct Case{ERR_GameMode mode;ERR_HQ hq;SCR_AIGroup group,foreign;IEntity one,two,extra;AIAgent a,b,c;Sampler sampler;ERR_HQUnit unit;VehicleOwner vehicle;TeamOwner team; + Case(){mode.command=&hq;ERR_GameMode::current=&mode;hq.m_Mode=&mode;hq.m_Units={&unit};unit.Group=&group;unit.StatusSampler=&sampler;sampler.group=&group;one.group=two.group=extra.group=&group;a.actor=&one;b.actor=&two;c.actor=&extra;sampler.members={&one,&two};group.agents={&a,&b};vehicle.Group=team.Group=&group;} + void Release(bool passenger){if(passenger)team.Release(&mode);else vehicle.Release(&mode);} +}; +int main(){ + for(bool passenger:{false,true}){ + Case c;c.unit.PendingConvoyOrder=true;c.unit.Downlink=true;c.Release(passenger); + assert(c.hq.unregistrations==0&&c.unit.Group==&c.group&&c.unit.Utility&&c.unit.reports); // old release silenced living original survivors + assert(!c.unit.ReservedForSupport&&c.unit.Eligible(&c.mode)&&!c.unit.Downlink&&c.unit.pendingClears==1); + assert(!c.mode.ledger.record.Pinned&&c.hq.registrations==0&&c.hq.equipmentStops==0); + assert(c.group.lod==-1&&c.a.writes==1&&c.b.writes==1); + c.Release(passenger);assert(c.unit.pendingClears==1&&c.a.writes==1); + } + for(bool passenger:{false,true}){ + Case c;c.one.fighting=false;c.two.alive=false;c.group.agents={&c.a};c.Release(passenger); + assert(c.hq.unregistrations==0&&!c.unit.Eligible(&c.mode)); // unconscious original retains future recovery/report identity + c.one.fighting=true;assert(c.unit.Eligible(&c.mode)); + } + {Case c;c.one.mounted=true;c.Release(false);assert(c.hq.unregistrations==0&&!c.unit.Eligible(&c.mode));c.one.mounted=false;assert(c.unit.Eligible(&c.mode));} + for(bool passenger:{false,true})for(int bad=0;bad<22;bad++){ + Case c; + if(bad==0)c.mode.active=false; + if(bad==1)c.hq.m_Stopping=true; + if(bad==2)ERR_GameMode::current=nullptr; + if(bad==3)c.mode.command=nullptr; + if(bad==4)c.group.players=1; + if(bad==5)c.group.faction=2; + if(bad==6)c.group.slave=true; + if(bad==7)c.mode.ledger.present=false; + if(bad==8)c.mode.ledger.record.Physical=false; + if(bad==9)c.mode.ledger.record.Eliminated=true; + if(bad==10)c.mode.ledger.record.Kind=ERR_ForceKind::GARRISON; + if(bad==11)c.unit.ForceSourceId="foreign"; + if(bad==12)c.sampler.source="foreign"; + if(bad==13)c.sampler.bound=false; + if(bad==14)c.one.group=&c.foreign; + if(bad==15)c.one.player=true; + if(bad==16)c.one.faction=2; + if(bad==17)c.group.agents.Insert(&c.c); + if(bad==18)c.group.agents.Insert(&c.a); + if(bad==19)c.group.agents={&c.b}; + if(bad==20)c.one.alive=c.two.alive=false; + if(bad==21)c.sampler.released[0]=true; + c.Release(passenger); + assert(c.hq.unregistrations==(bad==3?0:1));assert(c.unit.ReservedForSupport); + } + {Case c;ERR_ConvoyRules::captured=true;c.Release(false);assert(c.hq.unregistrations==1);ERR_ConvoyRules::captured=false;} + for(bool passenger:{false,true}){Case c;c.vehicle.Registered=c.team.Registered=false;c.Release(passenger);assert(c.hq.unregistrations==1);} + // Real HQ claim release keeps an unsettled inventory transaction quarantined, + // ignores somebody else's token and permits retasking only after exact settlement. + for(bool passenger:{false,true}){Case c;c.unit.LogisticsReservation=200007;c.Release(passenger); + assert(c.unit.LogisticsReservation==200007&&!c.unit.Eligible(&c.mode)); + c.hq.ReleaseLogistics(&c.group,200008);assert(!c.unit.Eligible(&c.mode)); + c.hq.ReleaseLogistics(&c.group,200007);assert(c.unit.Eligible(&c.mode)&&c.unit.reports); + } + {Case c;c.unit.Downlink=true;c.unit.PendingOrder=true;c.Release(false);assert(c.unit.Downlink&&c.unit.PendingOrder&&c.unit.pendingClears==0);} // no cancellation of another owner's order +} +''') +print('PASS: actual convoy/team Release retains original active survivors and HQ identity, rejects takeover/terminal/unknown roster, preserves exact logistics quarantine and normal eligibility')