Files
EscapistsReintroduced/docs/evidence/hq-lost-retask-2026-10-01/reviewed-change.patch.txt
T
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

213 lines
15 KiB
Plaintext

--- 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 <vector>
#include <algorithm>
#include <string>
-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<ERR_HQUnit*> 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);}};
template<class T>struct Array:std::vector<T>{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;