Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
221 lines
12 KiB
Python
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')
|