--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c @@ -1203,7 +1203,18 @@ else unit.CancelPending(); return; } - if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now) && !(task.OperationId > 0 && task.OperationRole == ERR_HQOperationRole.WITHDRAW)) { LostOrder(unit, now); return; } + if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) + { + bool recoveryOrder = task.OperationId > 0 && task.OperationRole == ERR_HQOperationRole.WITHDRAW; + if (m_Coordinated && task.OperationId > 0 && task.OperationRole == ERR_HQOperationRole.RESERVE) + { + // Pending radio orders exclude this squad from ObserveForces. Its delivered + // recovery judgment stays current until the exact queued task is validated below. + ERR_HQSquadJudgment squad = m_Operations.Judgment(unit.Id); + recoveryOrder = squad && squad.Recovering; + } + if (!recoveryOrder) { LostOrder(unit, now); return; } + } if (task.Investigation()) { if (!WatchTaskValid(task)) { LostOrder(unit, now); return; } --- a/tests/test_hq_ammo_dispatch.py +++ b/tests/test_hq_ammo_dispatch.py @@ -1,6 +1,8 @@ """Delivered ammo gates new tasks without becoming active-order cancellation.""" from policy_harness import ROOT, body, run -H=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c' +from pathlib import Path +import os +H=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c')) for method in ('protected void Investigate(', 'protected void Retask(', 'protected void Assign('): s=body(H,method) assert 'if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue;' in s @@ -8,20 +10,23 @@ s=body(H,'protected void DeliverOrder(') s=s[:s.index(' if (task.Investigation())')]+' admitted++;\n }' s=s.replace('protected ','').replace('null','nullptr').replace('.IsEmpty()', '.empty()').replace('ERR_HQUnit unit','ERR_HQUnit* unit').replace('ERR_HQOperationRole.','ERR_HQOperationRole::') +s=s.replace('ERR_HQSquadJudgment squad', 'ERR_HQSquadJudgment* squad').replace('squad.', 'squad->') s=s.replace('ERR_HQReport order','ERR_HQReport* order').replace('ERR_HQTask task','ERR_HQTask* task') for name in ('unit','task','order'):s=s.replace(name+'.',name+'->') run(r''' #include #include struct ERR_HQReport{}; -enum class ERR_HQOperationRole{SEARCH,WITHDRAW}; +enum class ERR_HQOperationRole{SEARCH,WITHDRAW,RESERVE}; +struct ERR_HQSquadJudgment{bool Recovering=false;}; +struct Operations{ERR_HQSquadJudgment* squad=nullptr;ERR_HQSquadJudgment* Judgment(int id){assert(id==1);return squad;}}; struct ERR_HQTask{int OperationId=0;ERR_HQOperationRole OperationRole=ERR_HQOperationRole::SEARCH;std::string SecuritySite;float ExpiresAt=100000;bool ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;}; struct ERR_HQUnit{ERR_HQReport* PendingOrder=nullptr;ERR_HQTask* PendingTask=nullptr;ERR_HQTask* Task=nullptr; - std::string ForceSourceId="original-1";bool eligible=true;int cancellations=0; + int Id=1;std::string ForceSourceId="original-1";bool eligible=true;int cancellations=0; bool Eligible(void*){return eligible;}void CancelPending(){cancellations++;PendingOrder=nullptr;PendingTask=nullptr;}}; struct Watch {bool empty=false;int calls=0;bool ArmedSearchBlocked(std::string s,float){assert(s=="original-1");calls++;return empty;}}; struct Journal {void Write(std::string,std::string){}}; -struct HQ{Watch m_Watch;Journal m_Journal;void* m_Mode=nullptr;int admitted=0,lost=0,released=0; +struct HQ{bool m_Coordinated=false;Operations m_Operations;Watch m_Watch;Journal m_Journal;void* m_Mode=nullptr;int admitted=0,lost=0,released=0; void LostOrder(ERR_HQUnit* u,float){lost++;u->CancelPending();}void ReleaseUnit(ERR_HQUnit* u,float){released++;u->Task=nullptr;u->CancelPending();} '''+s+r''' }; --- a/tests/test_hq_delivered_task.py +++ b/tests/test_hq_delivered_task.py @@ -7,13 +7,13 @@ def cpp(s): s=s.replace('EngineerMine.', 'EngineerMine->') s=s.replace('ref ', '') - s=s.replace('protected ','').replace('null','nullptr').replace('string.Format','Format') - for name in ('ERR_SupplyConvoyTactics','Math','ERR_GameMode','SCR_EntityHelper','ERR_HQPursuitRules','AIWaypoint','ERR_Actors','ERR_Resources','ERR_ForceDuty','ERR_HQRoadIntercept','ERR_ForceRules','ERR_HQPursuitRole','ERR_HQMemoryKind','ERR_HQOperationRole'): + s=s.replace('override ', '').replace('protected ','').replace('null','nullptr').replace('string.Format','Format') + for name in ('ERR_SupplyConvoyTactics','Math','ERR_GameMode','SCR_EntityHelper','ERR_HQPursuitRules','AIWaypoint','ERR_Actors','ERR_Resources','ERR_ForceDuty','ERR_HQRoadIntercept','ERR_ForceRules','ERR_HQPursuitRole','ERR_HQMemoryKind','ERR_HQOperationRole','ERR_HQExecutionRules'): s=s.replace(name+'.',name+'::') - for typ in ('ERR_HQUnit','ERR_HQTask','ERR_GameMode','ERR_HQReport','ERR_HQBelief','ERR_ForceRecord','ERR_HQHuntWindow','AIWaypoint'): + for typ in ('ERR_HQUnit','ERR_HQTask','ERR_GameMode','ERR_HQReport','ERR_HQBelief','ERR_ForceRecord','ERR_HQHuntWindow','AIWaypoint','ERR_HQSquadJudgment','ERR_HQOperationSlot'): s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s) s=re.sub(r'foreach \((ERR_HQUnit\*) (\w+) : ([^)]+)\)',r'for (\1 \2 : \3)',s) - for name in ('unit','other','task','planned','order','belief','record','mode','Group','hunt','waypoint','m_Mode','lostActive','lostPending'): + for name in ('unit','other','task','planned','order','belief','record','mode','Group','hunt','waypoint','m_Mode','lostActive','lostPending','squad','slot'): s=s.replace(name+'.',name+'->') s=s.replace('ResourceName ', 'int ').replace('.IsEmpty()', '.empty()') s=s.replace('PendingOrder.', 'PendingOrder->').replace('Assignment.', 'Assignment->') @@ -28,6 +28,8 @@ if 'protected void ReleaseLostForce(' in P.read_text(): methods=cpp(body(P,'protected void ReleaseLostForce('))+'\n'+methods missing=cpp(body(P,'if (!unit.Group && !unit.Uplink)')).replace('m_Units.Remove(i)', 'm_Units.erase(m_Units.begin()+i)') cleanup=cpp(body(P,' foreach (ERR_HQUnit unit : m_Units)\n {\n if (!unit.Assignment && !unit.PendingOrder) continue;\n bool security = SecurityAssigned(unit);')) +C=Path(os.environ.get('ERR_HQ_COORDINATED_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQCoordinated.c')) +methods=cpp(body(C,'override protected bool CoordinatedDelivery('))+'\n'+methods R=Path(os.environ.get('ERR_HQ_PURSUIT_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c')) rules='\n'.join(cpp(body(R,x)) for x in ('static bool Finite(', 'static bool PositionValid(', 'static bool Actionable(', 'static bool Heading(', 'static ERR_HQPursuitRole Role(')) eligible=cpp(body(ROAD,'static bool Eligible(')) @@ -49,7 +51,7 @@ '''+rules+r''' }; struct Math{static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}}; -struct ERR_HQTask{ERR_HQOperationRole OperationRole=ERR_HQOperationRole::SEARCH;int OperationId=0,ExecutionId=0;string SecuritySite;bool Maneuver=false,RoadIntercept=false,ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;string WatchSourceId;int WatchMessageId=0,WatchRequestId=0; +struct ERR_HQTask{ERR_HQOperationRole OperationRole=ERR_HQOperationRole::SEARCH;int OperationId=0,ExecutionId=0,OperationRevision=2,SupportSourceId=0;float SupportUntil=0;string SecuritySite;bool Maneuver=false,RoadIntercept=false,ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;string WatchSourceId;int WatchMessageId=0,WatchRequestId=0; int BeliefId=1,BeliefRevision=2;ERR_HQPursuitRole Role=ERR_HQPursuitRole::SEARCH;vector Destination{40,0,50};float SearchRadiusMetres=60,QueuedAt=0,ExpiresAt=60000;bool Investigation(){return !WatchSourceId.empty();}string Name(){return "search";}}; struct ERR_HQReport{string TravelMode="on foot";int MessageId=10;float ObservedAt=0;}; struct ERR_HQRoute{int MessageId=20;}; @@ -67,7 +69,7 @@ struct TestWorld{operator bool(){return true;}float GetWorldTime(){return 0;}};struct TestGame{operator bool(){return true;}TestWorld GetWorld(){return {};}};TestGame GetGame(){return {};}struct ExecutionDouble{void Update(void*,float){}operator bool(){return false;}void Stop(float,bool){}void Receive(ERR_HQTask*,float){}}; struct ERR_HQUnit{ExecutionDouble Execution;MineJob* EngineerMine=nullptr;int Id=1;string ForceSourceId="original";SCR_AIGroup* Group=nullptr;AIWaypoint* Waypoint=nullptr; ERR_HQTask* Task=nullptr;ERR_HQTask* PendingTask=nullptr;ERR_HQReport* Assignment=nullptr;ERR_HQReport* PendingOrder=nullptr; - ERR_HQRoute* Downlink=nullptr;ERR_HQRoute* Uplink=nullptr;void* PendingConvoyOrder=nullptr;bool HomeGuard=false,PendingCheckin=false,eligible=true,fighting=false,PlanEligible=true; + ERR_HQRoute* Downlink=nullptr;ERR_HQRoute* Uplink=nullptr;void* PendingConvoyOrder=nullptr;int LogisticsReservation=0;bool ReservedForSupport=false,HomeGuard=false,PendingCheckin=false,eligible=true,fighting=false,PlanEligible=true; bool Eligible(ERR_GameMode*){return eligible&&Group;} '''+units+r''' }; @@ -79,7 +81,11 @@ struct Watch{bool empty=false;int finished=0;bool ArmedSearchBlocked(string,float){return empty;}bool FinishInvestigation(string,int,int,float){finished++;return true;}}; struct Journal{int writes=0;void Write(string,string,int=0){writes++;}}; struct ERR_HQHuntWindow{float CompletedUntil=0;float NextHunt=200000;}; -struct HQ{bool CoordinatedDelivery(ERR_HQUnit*,ERR_HQTask*,float){return false;}struct Operations{void Release(int,int,float){}}m_Operations;ERR_GameMode* m_Mode;Memory m_Memory;Watch m_Watch;Journal m_Journal;std::vectorm_Units;ERR_HQHuntWindow hunt,otherHunt;bool watchValid=true; +struct ERR_HQSquadJudgment{bool Recovering=false;}; +struct ERR_HQOperationSlot{ERR_HQTask* Task=nullptr;}; +struct ERR_HQExecutionRules{static bool SupportFresh(float until,float now){return std::isfinite(until)&&until>now&&until<=now+20000;}}; +struct Operations{ERR_HQSquadJudgment* squad=nullptr;ERR_HQOperationSlot* slot=nullptr;ERR_HQSquadJudgment* Judgment(int id){assert(id==1);return squad;}ERR_HQOperationSlot* Find(int id){assert(id==1);return slot;}void Release(int,int,float){}}; +struct HQ{bool m_Coordinated=false;Operations m_Operations;ERR_GameMode* m_Mode;Memory m_Memory;Watch m_Watch;Journal m_Journal;std::vectorm_Units;ERR_HQHuntWindow hunt,otherHunt;bool watchValid=true; bool Fighting(ERR_HQUnit* u){return u->fighting;}bool WatchTaskValid(ERR_HQTask*){return watchValid;}ERR_HQHuntWindow* Hunt(int id){return id==2?&otherHunt:&hunt;} '''+methods+'\nvoid Cleanup(float now,int assigned,int globalLimit){'+cleanup+'}\nvoid RemoveMissing(float now){for(int i=int(m_Units.size())-1;i>=0;i--){ERR_HQUnit* unit=m_Units[i];'+missing+'}}'+r''' }; @@ -89,6 +95,50 @@ void Retained(){assert(unit.Task==&active&&unit.Assignment==&oldReport&&unit.Waypoint==&previous&&previous.deletes==0&&group.removals==0&&!unit.PendingTask&&!unit.PendingOrder&&!unit.Downlink);} }; int main(){ + // Real delivery and actual coordinated identity admission must permit rest for + // a known-empty recovering squad, without admitting ordinary reserve/attacks. + for(bool withdrawing:{false,true}){ + Fixture f;ERR_HQSquadJudgment mood;ERR_HQOperationSlot slot; + mood.Recovering=true;slot.Task=&f.next;f.hq.m_Operations.squad=&mood;f.hq.m_Operations.slot=&slot; + f.hq.m_Coordinated=true;f.hq.m_Watch.empty=true;f.next.OperationId=1;f.next.ExecutionId=12; + f.next.OperationRole=withdrawing?ERR_HQOperationRole::WITHDRAW:ERR_HQOperationRole::RESERVE; + // WITHDRAW keeps its original exemption without a recovery judgment. + if(withdrawing)f.hq.m_Operations.squad=nullptr; + f.hq.DeliverOrder(&f.unit,1000); + assert(f.unit.Task==&f.next&&f.unit.Assignment==&f.newReport&&f.unit.Waypoint&&f.unit.Waypoint->resource==2); + assert(ERR_Actors::spawns==1&&f.group.additions==1&&f.previous.deletes==1&&!f.unit.PendingTask); + } + for(int invalid=0;invalid<15;invalid++){ + Fixture f;ERR_HQSquadJudgment mood;ERR_HQOperationSlot slot;ERR_HQTask foreign; + mood.Recovering=true;slot.Task=&f.next;f.hq.m_Operations.squad=&mood;f.hq.m_Operations.slot=&slot; + f.hq.m_Coordinated=true;f.hq.m_Watch.empty=true;f.next.OperationId=1;f.next.ExecutionId=12;f.next.OperationRole=ERR_HQOperationRole::RESERVE; + if(invalid==0)mood.Recovering=false; + if(invalid==1)f.hq.m_Operations.squad=nullptr; + if(invalid==2)f.next.OperationId=0; + if(invalid==3)f.hq.m_Coordinated=false; + if(invalid==4)slot.Task=&foreign; + if(invalid==5)f.hq.m_Operations.slot=nullptr; + if(invalid==6)f.next.ExpiresAt=1000; + if(invalid==7)f.unit.eligible=false; + if(invalid==8)f.next.BeliefRevision=3; + if(invalid==9)f.next.OperationRevision=3; + if(invalid==10)f.unit.HomeGuard=true; + if(invalid==11)f.unit.ReservedForSupport=true; + if(invalid==12)f.unit.LogisticsReservation=1; + if(invalid==13)f.hq.m_Memory.belief=nullptr; + if(invalid==14)f.belief.Confidence=0; + f.hq.DeliverOrder(&f.unit,1000);f.Retained();assert(ERR_Actors::spawns==0); + } + for(auto role:{ERR_HQOperationRole::SEARCH,ERR_HQOperationRole::OBSERVE,ERR_HQOperationRole::FIX,ERR_HQOperationRole::FLANK,ERR_HQOperationRole::BLOCK}){ + Fixture f;ERR_HQSquadJudgment mood;ERR_HQOperationSlot slot;mood.Recovering=true;slot.Task=&f.next; + f.hq.m_Coordinated=true;f.hq.m_Watch.empty=true;f.hq.m_Operations.squad=&mood;f.hq.m_Operations.slot=&slot; + f.next.OperationId=1;f.next.OperationRole=role;f.hq.DeliverOrder(&f.unit,1000);f.Retained();assert(ERR_Actors::spawns==0); + } + // A tactical reserve still uses the ordinary ammo gate: with ammo present it + // is valid even without a recovery judgment; empty it is rejected above. + {Fixture f;ERR_HQOperationSlot slot;slot.Task=&f.next;f.hq.m_Coordinated=true;f.hq.m_Operations.slot=&slot; + f.next.OperationId=1;f.next.OperationRole=ERR_HQOperationRole::RESERVE;f.hq.DeliverOrder(&f.unit,1000);assert(f.unit.Task==&f.next&&ERR_Actors::spawns==1);} + // The copied radius is used before installation even if current uncertainty grows. for(float radius:{45.f,60.f,110.f}){Fixture f;f.next.SearchRadiusMetres=radius;f.belief.Radius=500; float deadline=f.next.ExpiresAt;f.hq.DeliverOrder(&f.unit,1000);