--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c @@ -925,21 +925,29 @@ m_Hunts.Insert(created); return created; } + // Only real surviving pursuit commitments can retain a failed order's hold. + protected float HuntCommitment(ERR_HQTask task, int beliefId, float now) + { + if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty() || task.Investigation()) return now + 15000; + if (task.BeliefId != beliefId || !(task.ExpiresAt > now)) return now + 15000; + return task.ExpiresAt + 180000; + } protected void LostOrder(ERR_HQUnit unit, float now) { - ERR_HQTask task = unit.PendingTask; + ref ERR_HQTask task = unit.PendingTask; unit.CancelPending(); if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty()) return; if (task.Investigation()) { FinishWatch(task, now); return; } + float nextHunt = now + 15000; foreach (ERR_HQUnit other : m_Units) { - ERR_HQTask planned = other.PlannedTask(); - if (planned && planned.BeliefId == task.BeliefId) return; - } - // A call that never arrived did not begin a hunt. Retry through the network - // after a short backoff, without bypassing another unit's active hunt window. + if (other.Assignment) nextHunt = Math.Max(nextHunt, HuntCommitment(other.Task, task.BeliefId, now)); + if (other.PendingOrder) nextHunt = Math.Max(nextHunt, HuntCommitment(other.PendingTask, task.BeliefId, now)); + } + // A lost long-distance retask cannot prolong an older delivered hunt. Keep + // both active and pending survivors' own deadlines, or the short retry delay. ERR_HQHuntWindow hunt = Hunt(task.BeliefId); - if (hunt) hunt.NextHunt = Math.Min(hunt.NextHunt, now + 15000); + if (hunt) hunt.NextHunt = Math.Min(hunt.NextHunt, nextHunt); } // Several squads may hear the same alarm. Reserve only one mobile search // across all alarm beliefs, including orders still travelling over radio. @@ -1304,7 +1312,7 @@ if (!unit.Assignment && !unit.PendingOrder) continue; if (!unit.PlanEligible || assigned > globalLimit) { FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now); unit.Release(); assigned--; continue; } if (unit.Task && now >= unit.Task.ExpiresAt) { FinishWatch(unit.Task, now); unit.ReleaseAssignment(); } - if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) { FinishWatch(unit.PendingTask, now); unit.CancelPending(); } + if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) LostOrder(unit, now); if (unit.Task && unit.Task.SecuritySite.IsEmpty() && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment(); if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && unit.PendingTask.SecuritySite.IsEmpty() && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending(); } --- a/tests/test_hq_assignment_lifecycle.py +++ b/tests/test_hq_assignment_lifecycle.py @@ -1,10 +1,16 @@ """Execute actual HQ assignment ownership and failed-radio backoff bodies.""" from policy_harness import ROOT, body, run -import re -p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c' +import os,re +from pathlib import Path +p=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c')) unit='\n'.join(body(p,s) for s in ('void CancelPending(', 'ERR_HQTask PlannedTask(', 'void ReleaseAssignment(', 'void Release()')) lost='\n'.join(body(p,s) for s in ('protected void FinishWatch(', 'protected void LostOrder(')).replace('protected ', '') +if 'protected float HuntCommitment(' in p.read_text(): lost=body(p,'protected float HuntCommitment(').replace('protected ', '')+'\n'+lost +retask=body(p,'protected void Retask(') +queue=retask[retask.index(' ERR_HQHuntWindow hunt = Hunt(belief.BeliefId);'):] +queue=queue[:queue.index(';',queue.index('if (hunt)'))+1] def adapt(code): + code=code.replace('ref ', '') code=code.replace('.IsEmpty()', '.empty()') code=code.replace('null','nullptr').replace('Math.','Math::').replace('ERR_GameMode.Get()', 'ERR_GameMode::Get()') code=code.replace('SCR_EntityHelper.DeleteEntityAndChildren', 'SCR_EntityHelper::DeleteEntityAndChildren') @@ -21,8 +27,8 @@ #include #include #include -struct Math {static float Min(float a,float b){return std::min(a,b);}}; -struct ERR_HQTask {bool ReleaseOnly=false;int BeliefId=0;std::string WatchSourceId,SecuritySite;int WatchMessageId=0,WatchRequestId=0;bool Investigation(){return !WatchSourceId.empty();}}; +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 {float ExpiresAt=120000;bool ReleaseOnly=false;int BeliefId=0;std::string WatchSourceId,SecuritySite;int WatchMessageId=0,WatchRequestId=0;bool Investigation(){return !WatchSourceId.empty();}}; struct ERR_HQReport {}; struct ERR_HQRoute {}; struct AIWaypoint {int deleted=0;}; @@ -46,7 +52,7 @@ bool FinishInvestigation(std::string s,int m,int r,float){calls++;source=s;message=m;request=r;return true;}}; struct HQ {Watch m_Watch;std::vector m_Units;ERR_HQHuntWindow window; ERR_HQHuntWindow* Hunt(int){return &window;} -'''+adapt(lost)+r'''}; +'''+adapt(lost)+'\nvoid QueueHorizon(ERR_HQTask* task,int id){struct Belief{int BeliefId;};Belief belief{id};'+adapt(queue)+'}'+r'''}; int main(){ ERR_GameMode mode;ERR_GameMode::current=&mode;ERR_ForceRecord record;mode.ledger.record=&record; SCR_AIGroup group;ERR_HQReport oldReport,newReport;ERR_HQTask oldTask,newTask;ERR_HQRoute route; @@ -82,9 +88,40 @@ hq.LostOrder(&u,1000);assert(hq.window.NextHunt==300000&&u.Task==&newTask&&u.Waypoint==&oldWaypoint); // Another group's pending hunt prevents duplicate retry commitments. u.Task=nullptr;u.Assignment=nullptr;u.Waypoint=nullptr; - ERR_HQUnit peer;peer.PendingTask=&newTask;hq.m_Units.push_back(&peer); + ERR_HQUnit peer;peer.PendingTask=&newTask;peer.PendingOrder=&newReport;hq.m_Units.push_back(&peer); u.PendingTask=&newTask;u.PendingOrder=&newReport;u.Downlink=&route; hq.LostOrder(&u,2000);assert(hq.window.NextHunt==300000); + // Execute the actual Retask deadline extension, then fail its downlink. + // The existing short delivered hunt must not inherit the failed long horizon. + {HQ h;ERR_HQUnit owner;ERR_HQTask delivered,pending;ERR_HQReport r;ERR_HQRoute link;AIWaypoint w; + delivered.BeliefId=7;delivered.ExpiresAt=120000;pending.BeliefId=7;pending.ExpiresAt=600000; + owner.Task=&delivered;owner.Assignment=&r;owner.Waypoint=&w; + owner.PendingTask=&pending;owner.PendingOrder=&r;owner.Downlink=&link;h.m_Units={&owner};h.window.NextHunt=300000; + h.QueueHorizon(&pending,7);assert(h.window.NextHunt==780000); + h.LostOrder(&owner,30000);assert(h.window.NextHunt==300000); + assert(owner.Task==&delivered&&owner.Assignment==&r&&owner.Waypoint==&w&&!owner.PendingTask); + // A surviving peer's longer pending order remains fully reserved. + ERR_HQUnit peer;ERR_HQTask active,replacement; + active.BeliefId=7;active.ExpiresAt=400000;replacement.BeliefId=7;replacement.ExpiresAt=700000; + peer.Task=&active;peer.Assignment=&r;peer.PendingTask=&replacement;peer.PendingOrder=&r;h.m_Units.push_back(&peer); + owner.PendingTask=&pending;owner.PendingOrder=&r;h.window.NextHunt=1000000; + h.LostOrder(&owner,30000);assert(h.window.NextHunt==880000&&peer.PendingTask==&replacement&&peer.Task==&active); + // An unrelated pending replacement cannot conceal its original same-track task. + replacement.BeliefId=8;owner.PendingTask=&pending;owner.PendingOrder=&r;h.window.NextHunt=1000000; + h.LostOrder(&owner,30000);assert(h.window.NextHunt==580000); + // Orphan task pointers, expired tasks and non-pursuit orders cannot hold a hunt. + owner.Task=nullptr;owner.Assignment=nullptr;peer.Assignment=nullptr;peer.PendingOrder=nullptr; + owner.PendingTask=&pending;owner.PendingOrder=&r;h.window.NextHunt=1000000; + h.LostOrder(&owner,30000);assert(h.window.NextHunt==45000); + for(int kind=0;kind<4;kind++){ + peer.Assignment=&r;active.ExpiresAt=400000;active.ReleaseOnly=false;active.SecuritySite="";active.WatchSourceId=""; + if(kind==0){active.ReleaseOnly=true;}if(kind==1){active.SecuritySite="site";}if(kind==2){active.WatchSourceId="patrol";}if(kind==3){active.ExpiresAt=30000;} + owner.PendingTask=&pending;owner.PendingOrder=&r;h.window.NextHunt=1000000; + h.LostOrder(&owner,30000);assert(h.window.NextHunt==45000);} + // Clamping only: even a surviving long commitment must not raise earlier pacing. + active.ExpiresAt=400000;owner.PendingTask=&pending;owner.PendingOrder=&r;h.window.NextHunt=40000; + h.LostOrder(&owner,30000);assert(h.window.NextHunt==40000); + } // A failed pending watch investigation completes only that pending lineage. ERR_HQTask activeWatch,pendingWatch;activeWatch.WatchSourceId="patrol-A";activeWatch.WatchMessageId=41;activeWatch.WatchRequestId=1; pendingWatch.WatchSourceId="patrol-B";pendingWatch.WatchMessageId=42;pendingWatch.WatchRequestId=2; @@ -107,4 +144,4 @@ hq.LostOrder(&u,2000);assert(hq.window.NextHunt==5000); } ''') -print('PASS: actual assignment cancellation/release preserves radio ownership, native waypoint lifecycle and failed-order retry boundaries') +print('PASS: actual failed-retask deadline rollback plus assignment cancellation/release preserves radio ownership, native waypoint lifecycle and failed-order retry boundaries') --- a/tests/test_hq_delivered_task.py +++ b/tests/test_hq_delivered_task.py @@ -5,6 +5,7 @@ P=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c')) ROAD=Path(os.environ.get('ERR_HQ_ROAD_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQRoadIntercept.c')) def cpp(s): + s=s.replace('ref ', '') s=s.replace('protected ','').replace('null','nullptr').replace('string.Format','Format') for name in ('Math','ERR_GameMode','SCR_EntityHelper','ERR_HQPursuitRules','AIWaypoint','ERR_Actors','ERR_Resources','ERR_ForceDuty','ERR_HQRoadIntercept','ERR_ForceRules','ERR_HQPursuitRole','ERR_HQMemoryKind'): s=s.replace(name+'.',name+'::') @@ -20,6 +21,7 @@ return s units='\n'.join(cpp(body(P,x)) for x in ('void CancelPending(', 'ERR_HQTask PlannedTask(', 'void ReleaseAssignment(', 'void Release()')) methods='\n'.join(cpp(body(P,x)) for x in ('protected bool AlarmCommitted(', 'protected void FinishWatch(', 'protected void ReleaseUnit(', 'protected void LostOrder(', 'protected void DeliverOrder(')) +if 'protected float HuntCommitment(' in P.read_text(): methods=cpp(body(P,'protected float HuntCommitment('))+'\n'+methods cleanup=cpp(body(P,' foreach (ERR_HQUnit unit : m_Units)\n {\n if (!unit.Assignment && !unit.PendingOrder) continue;\n if (!unit.PlanEligible || assigned > globalLimit)')) R=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(')) @@ -39,7 +41,7 @@ struct ERR_HQPursuitRules{ '''+rules+r''' }; -struct Math{static float Min(float a,float b){return std::min(a,b);}}; +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{string SecuritySite;bool 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 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;}; @@ -113,6 +115,11 @@ assert(f.mode.ledger.record.Duty==f.mode.ledger.record.HomeDuty);f.hq.Cleanup(1001,1,4);assert(f.previous.deletes==1&&f.unit.PendingTask==&f.next);} {Fixture f;f.active.BeliefId=1;f.next.ExpiresAt=1000;f.hq.Cleanup(1000,1,4);f.Retained();} {Fixture f;f.unit.PlanEligible=false;f.hq.Cleanup(1000,1,4);assert(!f.unit.Task&&!f.unit.PendingTask&&!f.unit.Downlink&&f.previous.deletes==1);} + // Expired undelivered retasks use the same bounded rollback as lost radio. + // Both original and replacement expire here; no unfulfilled hunt owns 780s. + {Fixture f;f.active.ExpiresAt=120000;f.next.ExpiresAt=600000;f.hq.hunt.NextHunt=780000; + f.hq.Cleanup(600000,1,4);assert(f.hq.hunt.NextHunt==615000); + assert(!f.unit.Task&&!f.unit.PendingTask&&!f.unit.Downlink&&f.previous.deletes==1);} // A still-live containment order also needs current authorization at delivery. // The fixed task deadline is not permission to use an older/weaker movement belief. for(int invalid=0;invalid<6;invalid++){Fixture f;f.next.Role=ERR_HQPursuitRole::CONTAIN; --- a/tests/test_hq_task_reservations.py +++ b/tests/test_hq_task_reservations.py @@ -14,6 +14,7 @@ depth+=(text[end]=='{')-(text[end]=='}');end+=1 return text[start:end] def cpp(s): + s=s.replace('ref ', '') s=s.replace('protected ','').replace('null','nullptr').replace('.IsEmpty()', '.empty()') for name in ('Math','ERR_HQPursuitRules','ERR_HQCoverageRules','vector'): s=s.replace(name+'.',name+'::') @@ -38,6 +39,7 @@ # All three planning paths must retain the same ownership rule. assert 'ReserveUnit(existing, reserved, first, second)' in body(P,'protected void Investigate(') methods='\n'.join(body(P,s) for s in ('protected int Assigned(', 'protected void FinishWatch(', 'protected void LostOrder(')) +if 'protected float HuntCommitment(' in P.read_text(): methods=body(P,'protected float HuntCommitment(')+'\n'+methods unit_methods='\n'.join(body(P,s) for s in ('void CancelPending(', 'ERR_HQTask PlannedTask(')) rules='\n'.join(body(R,s) for s in ('static bool Finite(', 'static bool PositionValid(', 'static bool BearingAllowed(')) coverage_rules='\n'.join(body(C,s) for s in ('static bool PositionValid(', 'static int Cell(', 'static int Key(', 'static bool Point(')) @@ -53,7 +55,7 @@ vector Vector(float x,float y,float z){return {x,y,z};} struct Math {static float Clamp(float x,float a,float b){return std::clamp(x,a,b);} static float Floor(float x){return std::floor(x);} static float AbsFloat(float x){return std::abs(x);} - static float Min(float a,float b){return std::min(a,b);}}; + static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}}; templatestruct Array:std::vector{int Count(){return this->size();}void Insert(T t){this->push_back(t);}}; struct ERR_HQPursuitRules { '''+cpp(rules)+r'''}; @@ -61,7 +63,7 @@ '''+cpp(coverage_rules)+r'''}; struct Coverage {bool Recent(vector,float){return false;} '''+cpp(coverage)+r'''}; -struct ERR_HQTask {vector Destination;int ApproachBearing=0,BeliefId=1;bool ReleaseOnly=false; +struct ERR_HQTask {float ExpiresAt=120000;vector Destination;int ApproachBearing=0,BeliefId=1;bool ReleaseOnly=false; std::string SecuritySite,WatchSourceId;int WatchMessageId=0,WatchRequestId=0;bool Investigation(){return !WatchSourceId.empty();}}; struct ERR_HQReport{};struct ERR_HQRoute{}; struct ERR_HQUnit {ERR_HQTask* Task=nullptr;ERR_HQTask* PendingTask=nullptr;