Files
EscapistsReintroduced/tests/test_hq_pursuit.py
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

221 lines
12 KiB
Python

"""Execute actual delivered-belief pursuit rules as C++; no engine/AI behavior claim."""
import os, re
from pathlib import Path
from policy_harness import ROOT, body, run
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'))
assignment = body(HQ, 'protected void Assign(')
selection = re.search(r'ERR_HQPursuitRole role = (ERR_HQPursuitRules\.Role\([^;]+\));', assignment)
assert selection, 'missing actual Assign role selection'
planner_role = selection.group(1).replace('ERR_HQPursuitRules.', 'Rules::')
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 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::')
methods = methods.replace('vector offset;', 'vector offset{};')
run(r'''
#include <cassert>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
#include <vector>
#include <set>
enum class ERR_HQPursuitRole { SEARCH, INTERCEPT, CONTAIN };
struct Math {
static float Clamp(float n,float lo,float hi){return std::clamp(n,lo,hi);}
static float AbsFloat(float n){return std::abs(n);}
static float Min(float a,float b){return std::min(a,b);}
};
struct vector {
float x=0,y=0,z=0;
vector()=default;
vector(float a,float b,float c):x(a),y(b),z(c){}
vector(const char* text){sscanf(text,"%f %f %f",&x,&y,&z);}
float& operator[](int i){return i==0?x:i==1?y:z;}
float operator[](int i)const{return i==0?x:i==1?y:z;}
vector operator+(vector b)const{return {x+b.x,y+b.y,z+b.z};}
vector operator-(vector b)const{return {x-b.x,y-b.y,z-b.z};}
vector operator*(float v)const{return {x*v,y*v,z*v};}
float Length()const{return std::sqrt(x*x+y*y+z*z);}
vector Normalized()const{float n=Length();return {x/n,y/n,z/n};}
static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}
static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}
};
vector Vector(float a,float b,float c){return {a,b,c};}
struct Rules {
'''+methods+r'''
};
struct Belief { float Confidence=.9f, Radius=50; vector Velocity; bool HasVelocity=true; };
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);
assert(Rules::Actionable(.9,10,50)&&Rules::Actionable(.15,600,2000));
assert(!Rules::Actionable(.149,10,50)&&Rules::Actionable(.9,601,50));
assert(Rules::Actionable(.2,1200,1900)&&!Rules::Actionable(.2,1200,2001));
assert(!Rules::Actionable(.9,-1,50)&&!Rules::Actionable(.9,0,-1));
float nan=std::numeric_limits<float>::quiet_NaN();
float inf=std::numeric_limits<float>::infinity();
assert(!Rules::Actionable(nan,0,50)&&!Rules::Actionable(.9,inf,50));
assert(!Rules::Actionable(.9,0,nan)&&!Rules::Actionable(1.1,0,50));
vector north{0,0,5}, center{1000,12,1000};
assert(Rules::Heading(north,true)&&!Rules::Heading(north,false));
assert(!Rules::Heading({0,0,0},true)&&!Rules::Heading({0,0,71},true));
assert(!Rules::Heading({nan,0,5},true));
assert(Rules::Role(.9,10,50,true,0)==Role::INTERCEPT);
assert(Rules::Role(.9,10,50,true,1)==Role::SEARCH);
assert(Rules::Role(.9,10,50,true,2)==Role::CONTAIN);
// Memory can retain a valid slow-motion sample below the directed-task threshold.
// The actual Assign expression must not queue containment that delivery rejects.
Belief belief;
for(float speed:{0.f,.49f,70.01f})for(int slot=0;slot<3;slot++){
belief.Velocity={0,0,speed};
assert(PlannerRole(belief,10,slot)==Role::SEARCH);
}
for(float speed:{.5f,70.f}){
belief.Velocity={0,0,speed};
assert(PlannerRole(belief,10,0)==Role::INTERCEPT);
assert(PlannerRole(belief,10,1)==Role::SEARCH);
assert(PlannerRole(belief,10,2)==Role::CONTAIN);
}
belief.Velocity={nan,0,5};
assert(PlannerRole(belief,10,2)==Role::SEARCH);
belief.Velocity=north;belief.HasVelocity=false;
assert(PlannerRole(belief,10,0)==Role::SEARCH&&PlannerRole(belief,10,2)==Role::SEARCH);
for(int slot=0;slot<3;slot++){
assert(Rules::Role(.64,10,50,true,slot)==Role::SEARCH);
assert(Rules::Role(.9,46,50,true,slot)==Role::SEARCH);
assert(Rules::Role(.9,10,151,true,slot)==Role::SEARCH);
assert(Rules::Role(.9,10,50,false,slot)==Role::SEARCH);
}
assert(Rules::Role(.79,10,50,true,2)==Role::SEARCH);
assert(Rules::Role(.9,10,101,true,2)==Role::SEARCH);
assert(Rules::Role(.9,10,50,true,-1)==Role::SEARCH);
assert(Rules::LifetimeSeconds(Role::INTERCEPT)==60);
assert(Rules::LifetimeSeconds(Role::CONTAIN)==90);
assert(Rules::LifetimeSeconds(Role::SEARCH)==120);
assert(Rules::TaskLifetimeSeconds(Role::SEARCH,0)==120);
assert(near(Rules::TaskLifetimeSeconds(Role::SEARCH,2000),2000.f/3+45));
assert(Rules::TaskLifetimeSeconds(Role::SEARCH,5000)==900);
assert(Rules::TaskLifetimeSeconds(Role::INTERCEPT,0)==180);
assert(near(Rules::TaskLifetimeSeconds(Role::INTERCEPT,2000),2000.f/3+180));
assert(Rules::TaskLifetimeSeconds(Role::INTERCEPT,5000)==900);
assert(Rules::TaskLifetimeSeconds(Role::INTERCEPT,-1)==0);
assert(Rules::TaskLifetimeSeconds(Role::INTERCEPT,nan)==0);
assert(Rules::TaskLifetimeSeconds(Role::CONTAIN,2000)==90);
assert(Rules::TaskLifetimeSeconds(Role::SEARCH,-1)==0);
assert(Rules::TaskLifetimeSeconds(Role::SEARCH,nan)==0);
assert(Rules::TaskLifetimeSeconds(Role::SEARCH,inf)==0);
// The caller must already own a physical, eligible and unassigned formation.
assert(Rules::CanCommit(true,true,false,false,0,0,3));
assert(!Rules::CanCommit(false,true,false,false,0,0,3));
assert(!Rules::CanCommit(true,false,false,false,0,0,3));
assert(!Rules::CanCommit(true,true,true,false,0,0,3));
assert(!Rules::CanCommit(true,true,false,false,0,-1,3));
assert(!Rules::CanCommit(true,true,false,false,0,0,0));
assert(!Rules::CanCommit(true,true,false,false,0,3,99));
assert(!Rules::CanCommit(true,true,false,true,1,0,3));
int homeAvailable=3,assigned=0;
for(int i=0;i<5;i++)if(Rules::CanCommit(true,true,false,true,homeAvailable,assigned,3)){
homeAvailable--;assigned++;
}
assert(homeAvailable==1&&assigned==2);
// Pending orders spend the slot immediately, and each busy unit is ineligible.
bool busy=false;int commitments=0;
for(int i=0;i<5;i++)if(Rules::CanCommit(true,true,busy,false,1,commitments,3)){
busy=true;commitments++;
}
assert(commitments==1);
vector intercept=Rules::Objective(center,north,true,.9,10,50,Role::INTERCEPT,0);
assert(near(intercept.x,1000)&&near(intercept.z,1065)&&near(intercept.y,12));
vector contain=Rules::Objective(center,north,true,.9,10,50,Role::CONTAIN,2);
assert(near(contain.x,1080)&&near(contain.z,1080));
vector stale=Rules::Objective(center,north,true,.4,180,400,Role::INTERCEPT,0);
assert(near(stale.z,1250)); // degraded to a bounded search sector, no velocity lead
for(int slot=-8;slot<9;slot++)for(float radius:{0.f,50.f,200.f,2000.f}){
vector objective=Rules::Objective(center,north,true,.2,300,radius,Role::SEARCH,slot);
assert(vector::DistanceXZ(center,objective)<=300&&near(objective.y,center.y));
}
vector maximum=Rules::Objective(center,{0,0,70},true,.9,0,50,Role::INTERCEPT,0);
assert(near(maximum.z,1200)); // future lead capped even for a fast vehicle
vector noDoubleAge=Rules::Objective(center,north,true,.9,40,50,Role::INTERCEPT,0);
assert(near(noDoubleAge.z,1060)); // supplied position is already memory-predicted
vector first=Rules::Objective(center,north,false,.4,180,100,Role::SEARCH,0);
vector second=Rules::Objective(center,north,false,.4,180,100,Role::SEARCH,1);
vector third=Rules::Objective(center,north,false,.4,180,100,Role::SEARCH,2);
assert(vector::DistanceXZ(first,second)>100&&vector::DistanceXZ(second,third)>100);
// Nearby units already ahead win interception; nearby correct-flank units contain.
float ahead=Rules::Score(Role::INTERCEPT,center,north,{1000,0,1200},intercept);
float behind=Rules::Score(Role::INTERCEPT,center,north,{1000,0,800},intercept);
assert(ahead>=0&&behind<0);
float right=Rules::Score(Role::CONTAIN,center,north,{1100,0,1050},contain);
float left=Rules::Score(Role::CONTAIN,center,north,{900,0,1050},contain);
assert(right<left);
assert(Rules::Score(Role::INTERCEPT,center,north,{intercept.x+150,0,intercept.z},intercept)>=0);
assert(Rules::Score(Role::INTERCEPT,center,north,{intercept.x+151,0,intercept.z},intercept)<0);
assert(Rules::Score(Role::CONTAIN,center,north,{contain.x+240,0,contain.z},contain)>=0);
assert(Rules::Score(Role::CONTAIN,center,north,{contain.x+241,0,contain.z},contain)<0);
assert(Rules::Score(Role::SEARCH,center,north,{1000,0,1000},first)
<Rules::Score(Role::SEARCH,center,north,{2000,0,1000},first));
assert(Rules::Score(Role::SEARCH,{nan,0,0},north,center,first)<0);
assert(Rules::Better(100,2,100,5)&&!Rules::Better(100,5,100,2));
assert(Rules::Better(99,5,100,2)&&Rules::Better(100,1,-1,-1));
assert(!Rules::Better(-1,1,100,2)&&!Rules::Better(nan,1,100,2));
int firstBearing=-1,secondBearing=-1;std::set<int> bearings;
std::vector<vector> forces={{1000,0,1200},{1200,0,1000},{1000,0,800},{800,0,1000}};
for(vector force:forces){
int bearing=Rules::Bearing(force,center);
if(!Rules::BearingAllowed(bearing,firstBearing,secondBearing))continue;
bearings.insert(bearing);
if(firstBearing<0)firstBearing=bearing;
else if(bearing!=firstBearing&&secondBearing<0)secondBearing=bearing;
}
assert(bearings.size()==2);
assert(Rules::BearingAllowed(firstBearing,firstBearing,secondBearing));
assert(!Rules::BearingAllowed(2,0,1)&&!Rules::BearingAllowed(3,0,1));
assert(!Rules::BearingAllowed(-1,-1,-1)&&!Rules::BearingAllowed(2,-1,1));
assert(Rules::Bearing({nan,0,0},center)==-1);
}
''')
for forbidden in ('GetGame(', 'GetOrigin(', 'GetPlayers(', 'GetControlledEntity(',
'Spawn(', 'CallLater(', 'SCR_AIGroup', 'IEntity'):
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')