Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
213 lines
15 KiB
Plaintext
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;
|