Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
231 lines
16 KiB
Diff
231 lines
16 KiB
Diff
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
|
|
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
|
|
@@ -1085,12 +1085,17 @@
|
|
if (task.RoadIntercept && !ERR_HQRoadIntercept.Eligible(belief, now)) { LostOrder(unit, now); return; }
|
|
}
|
|
if (Fighting(unit)) { LostOrder(unit, now); return; }
|
|
+ bool memorySearch = task.Role == ERR_HQPursuitRole.SEARCH && !task.Investigation()
|
|
+ && !task.RoadIntercept && task.SecuritySite.IsEmpty();
|
|
+ if (memorySearch && (!ERR_HQPursuitRules.Finite(task.SearchRadiusMetres)
|
|
+ || task.SearchRadiusMetres < 45 || task.SearchRadiusMetres > 110)) { LostOrder(unit, now); return; }
|
|
int deliveryId;
|
|
if (unit.Downlink) deliveryId = unit.Downlink.MessageId;
|
|
ResourceName waypointResource = ERR_Resources.SearchWaypoint();
|
|
if (task.RoadIntercept || !task.SecuritySite.IsEmpty()) waypointResource = ERR_Resources.DefendWaypoint();
|
|
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(waypointResource, ERR_Actors.Ground(task.Destination)));
|
|
if (!waypoint) { LostOrder(unit, now); m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; }
|
|
+ if (memorySearch) waypoint.SetCompletionRadius(task.SearchRadiusMetres);
|
|
if (unit.Assignment) RememberCompletedHunt(unit.Task, now);
|
|
unit.Release();
|
|
unit.Task = task;
|
|
@@ -1294,6 +1299,7 @@
|
|
if (!m_Coverage.Select(belief.PredictedPosition, belief.Radius, belief.Position, now, destination, reserved)) continue;
|
|
ERR_HQTask task = new ERR_HQTask();
|
|
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = ERR_HQPursuitRole.SEARCH;
|
|
+ task.SearchRadiusMetres = ERR_HQPursuitRules.SearchRadius(belief.Radius);
|
|
task.Destination = destination; task.QueuedAt = now;
|
|
task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(task.Role, vector.DistanceXZ(unit.PlanPosition, destination)) * 1000;
|
|
task.ApproachBearing = bearing;
|
|
@@ -1544,6 +1550,7 @@
|
|
if (!best) continue;
|
|
ERR_HQTask task = new ERR_HQTask();
|
|
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = role; task.RoadIntercept = roadIntercept;
|
|
+ if (role == ERR_HQPursuitRole.SEARCH && !roadIntercept) task.SearchRadiusMetres = ERR_HQPursuitRules.SearchRadius(belief.Radius);
|
|
task.Destination = destination; task.QueuedAt = now;
|
|
task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(role, vector.DistanceXZ(best.PlanPosition, destination)) * 1000;
|
|
task.ApproachBearing = bestBearing;
|
|
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c
|
|
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c
|
|
@@ -41,6 +41,13 @@
|
|
if (slot == 0) return ERR_HQPursuitRole.INTERCEPT;
|
|
if (slot == 2 && confidence >= 0.8 && uncertainty <= 100) return ERR_HQPursuitRole.CONTAIN;
|
|
return ERR_HQPursuitRole.SEARCH;
|
|
+ }
|
|
+ // Radius of one native search grid, copied from delivered uncertainty when
|
|
+ // the task is queued. This does not enlarge the reported swept-ground proof.
|
|
+ static float SearchRadius(float uncertainty)
|
|
+ {
|
|
+ if (!Finite(uncertainty) || uncertainty < 0) return 0;
|
|
+ return Math.Clamp(uncertainty, 45, 110);
|
|
}
|
|
static float LifetimeSeconds(ERR_HQPursuitRole role)
|
|
{
|
|
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQTask.c
|
|
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQTask.c
|
|
@@ -12,6 +12,7 @@
|
|
int BeliefId;
|
|
int BeliefRevision;
|
|
ERR_HQPursuitRole Role;
|
|
+ float SearchRadiusMetres;
|
|
vector Destination;
|
|
float QueuedAt;
|
|
float ExpiresAt;
|
|
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_NativePatrolHQProbe.c
|
|
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativePatrolHQProbe.c
|
|
@@ -20,6 +20,7 @@
|
|
if (!belief) return false;
|
|
ERR_HQTask task = new ERR_HQTask();
|
|
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision;
|
|
+ task.SearchRadiusMetres = ERR_HQPursuitRules.SearchRadius(belief.Radius);
|
|
task.Destination = order.Position; task.QueuedAt = order.ObservedAt;
|
|
task.ExpiresAt = order.ObservedAt + 120000;
|
|
unit.PendingTask = task;
|
|
--- a/tests/test_hq_delivered_task.py
|
|
+++ b/tests/test_hq_delivered_task.py
|
|
@@ -13,7 +13,7 @@
|
|
for typ in ('ERR_HQUnit','ERR_HQTask','ERR_GameMode','ERR_HQReport','ERR_HQBelief','ERR_ForceRecord','ERR_HQHuntWindow','AIWaypoint'):
|
|
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','m_Mode','lostActive','lostPending'):
|
|
+ for name in ('unit','other','task','planned','order','belief','record','mode','Group','hunt','waypoint','m_Mode','lostActive','lostPending'):
|
|
s=s.replace(name+'.',name+'->')
|
|
s=s.replace('ResourceName ', 'int ').replace('.IsEmpty()', '.empty()')
|
|
s=s.replace('PendingOrder.', 'PendingOrder->').replace('Assignment.', 'Assignment->')
|
|
@@ -28,7 +28,7 @@
|
|
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 if (!unit.PlanEligible || assigned > globalLimit)'))
|
|
-R=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c'
|
|
+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('))
|
|
run(r'''
|
|
@@ -37,6 +37,7 @@
|
|
#include <string>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
+#include <limits>
|
|
using string=std::string;
|
|
template<class... T>string Format(T...){return "order";}
|
|
struct vector{float x=0,y=0,z=0;float& operator[](int i){return i==0?x:(i==1?y:z);}float Length(){return std::sqrt(x*x+y*y+z*z);}};
|
|
@@ -48,10 +49,10 @@
|
|
};
|
|
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";}};
|
|
+ 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;};
|
|
-struct AIWaypoint{int resource=0,deletes=0;static AIWaypoint* Cast(AIWaypoint* p){return p;}};
|
|
+struct AIWaypoint{int resource=0,deletes=0,configured=0;float radius=30;void SetCompletionRadius(float r){assert(!configured);radius=r;configured++;}static AIWaypoint* Cast(AIWaypoint* p){return p;}};
|
|
struct SCR_AIGroup{int removals=0,additions=0;AIWaypoint* last=nullptr;void RemoveWaypoint(AIWaypoint* w){removals++;last=w;}void AddWaypointAt(AIWaypoint* w,int at){assert(at==0);additions++;last=w;}};
|
|
struct ERR_ForceRecord{bool Eliminated=false;int Duty=2,HomeDuty=1;vector DutyTarget,HomePosition;};
|
|
struct Ledger{ERR_ForceRecord record;ERR_ForceRecord* FindGroup(SCR_AIGroup*){return &record;}};
|
|
@@ -85,6 +86,23 @@
|
|
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(){
|
|
+ // 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);
|
|
+ assert(f.group.additions==1&&f.unit.Waypoint->radius==radius&&f.unit.Waypoint->configured==1);
|
|
+ assert(f.next.SearchRadiusMetres==radius&&f.next.ExpiresAt==deadline);}
|
|
+ // Malformed snapshot cannot spawn or replace the delivered previous assignment.
|
|
+ for(float radius:{0.f,44.99f,110.01f,-1.f,std::numeric_limits<float>::quiet_NaN(),std::numeric_limits<float>::infinity()}){
|
|
+ Fixture f;f.next.SearchRadiusMetres=radius;f.hq.DeliverOrder(&f.unit,1000);f.Retained();assert(ERR_Actors::spawns==0);}
|
|
+ // Missing patrol, road/security defend, interception and containment keep native defaults.
|
|
+ for(int kind=0;kind<5;kind++){Fixture f;f.next.SearchRadiusMetres=std::numeric_limits<float>::quiet_NaN();
|
|
+ if(kind==0)f.next.WatchSourceId="missing-original";
|
|
+ if(kind==1)f.next.RoadIntercept=true;
|
|
+ if(kind==2)f.next.SecuritySite="site";
|
|
+ if(kind==3)f.next.Role=ERR_HQPursuitRole::INTERCEPT;
|
|
+ if(kind==4){f.next.Role=ERR_HQPursuitRole::CONTAIN;f.belief.Confidence=.9f;}
|
|
+ f.hq.DeliverOrder(&f.unit,1000);assert(f.group.additions==1&&f.unit.Waypoint->radius==30&&f.unit.Waypoint->configured==0);}
|
|
+
|
|
// Alarm-only deliveries retain their weak SEARCH/mobile policy even if a queued task is tampered with.
|
|
for(int invalid=0;invalid<5;invalid++){Fixture f;f.belief.Kind=ERR_HQMemoryKind::ALARM;
|
|
ERR_HQUnit other;ERR_HQTask otherTask;ERR_HQReport alarm;alarm.TravelMode="heard vehicle alarm";
|
|
@@ -217,3 +235,6 @@
|
|
}
|
|
''')
|
|
print('PASS: actual delivered-task lineage, expiry, old-order retention, native commit, release, road precision expiry and duplicate delivery')
|
|
+
|
|
+delivery=body(P,'protected void DeliverOrder(')
|
|
+assert delivery.index('waypoint.SetCompletionRadius(task.SearchRadiusMetres)') < delivery.index('unit.Release();') < delivery.index('unit.Group.AddWaypointAt')
|
|
--- a/tests/test_hq_planner_budget.py
|
|
+++ b/tests/test_hq_planner_budget.py
|
|
@@ -44,13 +44,14 @@
|
|
namespace ERR_HQMemoryKind{enum{SIGHTING,GUNFIRE,ALARM};}
|
|
namespace ERR_HQPursuitRole{enum{SEARCH,INTERCEPT,CONTAIN};}
|
|
struct ERR_HQReport{int id=0;ERR_HQReport* Snapshot(){return this;}};
|
|
-struct ERR_HQTask{int BeliefId=0,BeliefRevision=0,Role=0,ApproachBearing=0;bool RoadIntercept=false;Vec Destination;float QueuedAt=0,ExpiresAt=0;string Name(){return "task";}};
|
|
+struct ERR_HQTask{int BeliefId=0,BeliefRevision=0,Role=0,ApproachBearing=0;bool RoadIntercept=false;Vec Destination;float SearchRadiusMetres=0,QueuedAt=0,ExpiresAt=0;string Name(){return "task";}};
|
|
struct ERR_HQRoute{int MessageId=0;string Payload;};
|
|
struct Group{Vec pos;Vec GetOrigin(){return pos;}};
|
|
struct ERR_HQUnit{int Id=0,Radio=0,HomeId=0,ForceSourceId=0;struct Group* Group=nullptr;ERR_HQReport* Assignment=nullptr;ERR_HQReport* PendingOrder=nullptr;ERR_HQTask* PendingTask=nullptr;ERR_HQRoute* Downlink=nullptr;bool PlanFighting=false,PlanEligible=true,HomeGuard=false;Vec PlanPosition;};
|
|
struct ERR_HQBelief{int BeliefId=0,BeliefRevision=1,MessageId=1,Kind=0;float Confidence=.7,ObservedAt=1000,Radius=100;Vec Position,PredictedPosition,Velocity;bool HasVelocity=true;ERR_HQReport* Report=nullptr;};
|
|
struct ERR_HQHuntWindow{float NextHunt=0;};
|
|
struct ERR_HQPursuitRules{
|
|
+ static float SearchRadius(float radius){return std::clamp(radius,45.f,110.f);}
|
|
static bool Actionable(float c,float age,float r){return c>=.15&&age>=0&&r<=2000;}
|
|
static bool Heading(Vec,bool h){return h;}
|
|
static int Role(float,float,float,bool h,int slot){return h?slot:0;}
|
|
@@ -91,7 +92,7 @@
|
|
}
|
|
};
|
|
void Equal(Setup&a,Setup&b){assert(a.h.m_Journal.writes==b.h.m_Journal.writes&&a.h.m_Coverage.selections==b.h.m_Coverage.selections&&a.h.m_HomeAvailable.v==b.h.m_HomeAvailable.v);
|
|
- for(int i=0;i<128;i++){auto x=a.units[i].PendingTask,y=b.units[i].PendingTask;assert(bool(x)==bool(y));if(!x)continue;assert(x->BeliefId==y->BeliefId&&x->Role==y->Role&&x->RoadIntercept==y->RoadIntercept&&x->Destination==y->Destination&&x->ExpiresAt==y->ExpiresAt&&x->ApproachBearing==y->ApproachBearing);}
|
|
+ for(int i=0;i<128;i++){auto x=a.units[i].PendingTask,y=b.units[i].PendingTask;assert(bool(x)==bool(y));if(!x)continue;assert(x->SearchRadiusMetres==y->SearchRadiusMetres&&x->BeliefId==y->BeliefId&&x->Role==y->Role&&x->RoadIntercept==y->RoadIntercept&&x->Destination==y->Destination&&x->ExpiresAt==y->ExpiresAt&&x->ApproachBearing==y->ApproachBearing);}
|
|
for(auto [id,window]:a.h.hunts)assert(window.NextHunt==b.h.hunts[id].NextHunt);
|
|
}
|
|
int main(){
|
|
--- a/tests/test_hq_pursuit.py
|
|
+++ b/tests/test_hq_pursuit.py
|
|
@@ -2,7 +2,7 @@
|
|
import os, re
|
|
from pathlib import Path
|
|
from policy_harness import ROOT, body, run
|
|
-P = ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c'
|
|
+P = Path(os.environ.get('ERR_HQ_PURSUIT_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c'))
|
|
source = P.read_text()
|
|
# Exercise the real planner expression against the same actual policy methods.
|
|
HQ = Path(os.environ.get('ERR_HQ_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c'))
|
|
@@ -13,7 +13,7 @@
|
|
methods = '\n'.join(body(P, signature) for signature in (
|
|
'static bool Finite(', 'static bool PositionValid(', 'static int Limit(', 'static bool Actionable(',
|
|
'static bool Heading(', 'static ERR_HQPursuitRole Role(', 'static float LifetimeSeconds(',
|
|
- 'static float TaskLifetimeSeconds(', 'static bool CanCommit(', 'static int Bearing(', 'static bool BearingAllowed(', 'static vector Direction(',
|
|
+ 'static float SearchRadius(', 'static float TaskLifetimeSeconds(', 'static bool CanCommit(', 'static int Bearing(', 'static bool BearingAllowed(', 'static vector Direction(',
|
|
'static vector Objective(', 'static float Score(', 'static bool Better('))
|
|
methods = methods.replace('Math.', 'Math::').replace('vector.', 'vector::')
|
|
methods = methods.replace('ERR_HQPursuitRole.', 'ERR_HQPursuitRole::')
|
|
@@ -55,6 +55,10 @@
|
|
ERR_HQPursuitRole PlannerRole(Belief belief,float age,int slot){return '''+planner_role+r''';}
|
|
bool near(float a,float b){return std::abs(a-b)<0.001f;}
|
|
int main(){
|
|
+ assert(Rules::SearchRadius(0)==45&&Rules::SearchRadius(44.99)==45&&Rules::SearchRadius(45)==45);
|
|
+ assert(Rules::SearchRadius(73)==73&&Rules::SearchRadius(110)==110&&Rules::SearchRadius(2000)==110);
|
|
+ assert(Rules::SearchRadius(-1)==0&&Rules::SearchRadius(std::numeric_limits<float>::quiet_NaN())==0&&Rules::SearchRadius(std::numeric_limits<float>::infinity())==0);
|
|
+
|
|
using Role=ERR_HQPursuitRole;
|
|
assert(Rules::Limit(-1)==0&&Rules::Limit(0)==0&&Rules::Limit(1)==1);
|
|
assert(Rules::Limit(3)==3&&Rules::Limit(8)==3);
|
|
@@ -186,3 +190,27 @@
|
|
assert forbidden not in source, f'pursuit must consume snapshots only: {forbidden}'
|
|
assert all(len(line)<120 for line in source.splitlines())
|
|
print('PASS: actual bounded pursuit roles/objectives/scores, stale search degradation, physical/busy/home guards, finite lifetime and two-bearing escape gap; no hidden player reads or spawning')
|
|
+
|
|
+for signature in ('protected void Assign(', 'protected void Retask('):
|
|
+ section=body(HQ,signature)
|
|
+ line=next(line.strip() for line in section.splitlines() if 'task.SearchRadiusMetres =' in line)
|
|
+ line=line.replace('ERR_HQPursuitRules.', 'Rules::').replace('ERR_HQPursuitRole.', 'Role::')
|
|
+ run(r"""
|
|
+#include <cassert>
|
|
+#include <algorithm>
|
|
+#include <cmath>
|
|
+#include <limits>
|
|
+enum class Role {SEARCH,INTERCEPT,CONTAIN};
|
|
+struct Math{static float Clamp(float n,float a,float b){return std::clamp(n,a,b);}};
|
|
+struct Rules{
|
|
+"""+body(P,'static bool Finite(')+body(P,'static float SearchRadius(').replace('Math.','Math::')+r"""
|
|
+};
|
|
+struct Task{float SearchRadiusMetres=0;};struct Belief{float Radius=0;};
|
|
+void Queue(Task& task,Belief& belief,Role role,bool roadIntercept){
|
|
+"""+line+r"""
|
|
+}
|
|
+int main(){for(float radius:{0.f,60.f,500.f}){Task task;Belief belief{radius};Queue(task,belief,Role::SEARCH,false);float copied=Rules::SearchRadius(radius);assert(task.SearchRadiusMetres==copied);belief.Radius=1900;assert(task.SearchRadiusMetres==copied);}
|
|
+"""+(r"""for(Role role:{Role::INTERCEPT,Role::CONTAIN}){Task task;Belief belief{100};Queue(task,belief,role,false);assert(task.SearchRadiusMetres==0);}Task road;Belief belief{100};Queue(road,belief,Role::SEARCH,true);assert(road.SearchRadiusMetres==0);""" if 'Assign' in signature else '')+r"""
|
|
+}
|
|
+""")
|
|
+print('PASS: actual finite45..110 radius and both ordinary search queue snapshot expressions')
|