--- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQMountedIntercept.c @@ -0,0 +1,109 @@ +// Detached radio order. Only its issuer chooses Position and the fixed deadline. +class ERR_ConvoyInterceptOrder +{ + int Token; + SCR_AIGroup Group; + string SourceId; + int VehicleIndex; + vector Position; + float IssuedAt; + float ExpiresAt; + float ObservedAt; + int BeliefId; + int BeliefRevision; + int ReportId; +} + +// Delivered ground history only; no target entity reads, force creation or orders. +class ERR_HQMountedInterceptRules +{ + static bool Eligible(ERR_HQBelief belief, float now) + { + if (!ERR_HQRoadIntercept.Eligible(belief, now)) return false; + if (belief.BeliefId <= 0 || belief.BeliefRevision <= 0 || belief.ObservedAt < 0) return false; + return ERR_HQPursuitRules.PositionValid(belief.Position) && ERR_HQPursuitRules.PositionValid(belief.PredictedPosition); + } + static bool Current(ERR_ConvoyInterceptOrder order, ERR_HQBelief belief, float now) + { + if (!order || !Eligible(belief, now) || !order.Group || order.SourceId.IsEmpty()) return false; + if (order.Token < 400001 || order.Token > 400256) return false; + if (order.VehicleIndex != 0 && order.VehicleIndex != 3 && order.VehicleIndex != 5) return false; + if (order.BeliefId != belief.BeliefId || order.BeliefRevision != belief.BeliefRevision + || order.ReportId != belief.MessageId || order.ObservedAt != belief.ObservedAt) return false; + if (!ERR_ForceRules.Finite(order.IssuedAt) || !ERR_ForceRules.Finite(order.ExpiresAt)) return false; + if (order.IssuedAt < belief.LastDeliveredAt || now < order.IssuedAt || now - order.IssuedAt > 30000) return false; + if (order.ExpiresAt <= order.IssuedAt || order.ExpiresAt > order.ObservedAt + 120000 || now >= order.ExpiresAt) return false; + return ERR_HQPursuitRules.PositionValid(order.Position); + } + static bool Ahead(vector origin, vector velocity, vector point) + { + if (!ERR_ForceRules.FiniteVector(origin) || !ERR_ForceRules.FiniteVector(point) || !ERR_HQPursuitRules.Heading(velocity, true)) return false; + vector delta = point - origin; delta[1] = 0; velocity[1] = 0; + float distance = delta.Length(); + return distance >= 300 && distance <= 900 && vector.Dot(delta.Normalized(), velocity.Normalized()) >= 0.8; + } + static bool FromPolyline(array points, vector origin, vector velocity, vector seed, array reserved, out vector result, out float score, out float heading) + { + result = vector.Zero; score = 1e20; heading = 0; + if (!points || points.Count() < 2 || points.Count() > 1024 || !ERR_ForceRules.FiniteVector(seed)) return false; + foreach (vector point : points) if (!ERR_ForceRules.FiniteVector(point)) return false; + bool found; + for (int i = 1; i < points.Count(); i++) + { + vector delta = points[i] - points[i - 1]; delta[1] = 0; + float length = delta.Length(); + if (length < 0.01) continue; + vector offset = seed - points[i - 1]; offset[1] = 0; + float fraction = Math.Clamp(vector.Dot(offset, delta) / (length * length), 0, 1); + vector candidate = vector.Lerp(points[i - 1], points[i], fraction); + float distance = vector.DistanceXZ(seed, candidate); + if (distance > 100 || !Ahead(origin, velocity, candidate) || !ERR_HQRoadIntercept.Unreserved(candidate, reserved)) continue; + if (!found || ERR_HQRoadIntercept.Better(distance, candidate, score, result)) { result = candidate; score = distance; heading = Math.Atan2(delta[0], delta[2]) * Math.RAD2DEG; found = true; } + } + return found; + } + static bool Select(ERR_HQBelief belief, float now, array reserved, out vector result) + { + result = vector.Zero; + if (!Replication.IsServer() || !Eligible(belief, now) || !reserved || reserved.Count() > 128) return false; + if (!GetGame() || !GetGame().GetWorld()) return false; + vector velocity = belief.Velocity; velocity[1] = 0; + vector seed = belief.PredictedPosition + velocity.Normalized() * Math.Clamp(velocity.Length() * 60, 300, 900); + if (!ERR_HQPursuitRules.PositionValid(seed)) return false; + SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld()); + if (!world || !world.GetRoadNetworkManager()) return false; + array roads = {}; + world.GetRoadNetworkManager().GetRoadsInAABB(seed + "-100 -1000 -100", seed + "100 1000 100", roads); + if (roads.Count() > 128) return false; + int segments; + bool found; + vector best; + float bestScore = 1e20; + float bestHeading; + foreach (BaseRoad road : roads) + { + if (!road || !ERR_ForceRules.Finite(road.GetWidth()) || road.GetWidth() < 6) continue; + array points = {}; road.GetPoints(points); + if (points.Count() < 2 || points.Count() > 1024) continue; + segments += points.Count() - 1; + if (segments > 4096) return false; + vector candidate; + float score, heading; + if (!FromPolyline(points, belief.PredictedPosition, velocity, seed, reserved, candidate, score, heading)) continue; + if (!found || ERR_HQRoadIntercept.Better(score, candidate, bestScore, best)) + { best = candidate; bestScore = score; bestHeading = heading; found = true; } + } + if (!found) return false; + vector grounded = ERR_Actors.Ground(best); + // Never convert an elevated bridge point into terrain beneath the road. + if (!ERR_HQPursuitRules.PositionValid(grounded) || Math.AbsFloat(grounded[1] - best[1]) > 2) return false; + ERR_SettlementSafety settlements = new ERR_SettlementSafety(); + if (!settlements.OutsideSettlements(grounded)) return false; + // Existing conservative vehicle footprint: nine terrain/water samples, + // filled collision volume and segment-aligned heading; no scenery changes. + ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement(); + if (!placement.Clear(grounded, bestHeading)) return false; + result = grounded; + return true; + } +} --- /dev/null +++ b/tests/test_hq_mounted_intercept.py @@ -0,0 +1,102 @@ +"""Actual delivered-memory road selector, geometry and native-query boundaries.""" +from pathlib import Path +import os,re +from policy_harness import ROOT,body,run +p=Path(os.environ.get('ERR_MOUNTED_INTERCEPT_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQRoadIntercept.c')) +source=p.read_text() +base=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQRoadIntercept.c' +source='class ERR_HQRoadIntercept\n{\n'+'\n'.join(body(base,x) for x in ('static bool Eligible(', 'static bool Unreserved(', 'static bool Better('))+'\n}\n'+source +for forbidden in ('GetPlayer','GetControlledEntity','GetOrigin','Spawn(', 'AddWaypoint','SetOrigin','CallLater'): + assert forbidden not in source,forbidden +rules='\n'.join(body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c', x) for x in ('static bool Finite(', 'static bool PositionValid(', 'static bool Actionable(', 'static bool Heading(', 'static ERR_HQPursuitRole Role(', 'static vector Direction(', 'static vector Objective(')) +rules=rules.replace('Math.','Math::').replace('vector.','vector::').replace('ERR_HQPursuitRole.','ERR_HQPursuitRole::').replace('vector offset;','vector offset{};') +s=re.sub(r'//[^\n]*','',source).replace('class ERR_HQRoadIntercept','struct ERR_HQRoadIntercept').replace('class ERR_HQMountedInterceptRules','struct ERR_HQMountedInterceptRules').replace('class ERR_ConvoyInterceptOrder','struct ERR_ConvoyInterceptOrder') +s=s.replace('out vector','vector&').replace('out float','float&').replace('array','Array').replace('array','Array').replace('foreach (','for (') +s=s.replace('SCR_AIWorld.Cast(GetGame().GetAIWorld())','GetGame()->GetAIWorld()') +for name in ('ERR_HQBelief','SCR_AIWorld','BaseRoad','TraceBox','SCR_AIGroup','ERR_ConvoyInterceptOrder','ERR_SettlementSafety','ERR_ConvoyPlacement'): + s=re.sub(r'\b'+name+r' (\w+)',name+r'* \1',s) +for name in ('belief','world','road','body','order','settlements','placement'): + s=s.replace(name+'.',name+'->') +s=s.replace('GetGame().','GetGame()->').replace('GetRoadNetworkManager().','GetRoadNetworkManager()->').replace('GetWorld().','GetWorld()->') +for name in ('ERR_ForceRules','ERR_HQPursuitRules','ERR_HQPursuitRole','ERR_HQMemoryKind','vector','Math','ERR_Actors','ChimeraWorldUtils','TraceFlags','Replication','ERR_HQRoadIntercept'): + s=s.replace(name+'.',name+'::') +s=s.replace('null','nullptr').replace('string SourceId','std::string SourceId').replace('.IsEmpty()', '.empty()') +s=re.sub(r'\b(bool|int|float) (\w+);',r'\1 \2{};',s).replace('float score, heading;', 'float score=0, heading=0;').replace('\n}\n','\n};\n') +run(r''' +#include +#include +#include +#include +#include +#include +#include +enum class ERR_HQPursuitRole{SEARCH,INTERCEPT,CONTAIN};enum class ERR_HQMemoryKind{SIGHTING,DISTRESS,BODY}; +struct SCR_AIGroup{}; +struct Math{static constexpr float RAD2DEG=57.2957795131f;static float Atan2(float x,float y){return std::atan2(x,y);}static float Clamp(float x,float a,float b){return std::clamp(x,a,b);}static float AbsFloat(float x){return std::abs(x);}}; +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*s){sscanf(s,"%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 n)const{return{x*n,y*n,z*n};}bool operator==(vector b)const{return x==b.x&&y==b.y&&z==b.z;} + 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);}static vector Lerp(vector a,vector b,float n){return a+(b-a)*n;}static const vector Zero;}; +const vector vector::Zero{};vector Vector(float a,float b,float c){return{a,b,c};} +templatestruct Array:std::vector{using std::vector::vector;using std::vector::operator=;int Count(){return this->size();}void Insert(T v){this->push_back(v);}explicit operator bool(){return true;}}; +struct ERR_ForceRules{static bool Finite(float n){return std::isfinite(n);}static bool FiniteVector(vector p){return Finite(p.x)&&Finite(p.y)&&Finite(p.z);}}; +struct ERR_HQPursuitRules{''' + rules + r'''}; +struct ERR_HQBelief{ERR_HQMemoryKind Kind=ERR_HQMemoryKind::SIGHTING;int SampleCount=2,MessageId=1,BeliefId=3,BeliefRevision=2;vector Position={1000,10,1000};float ObservedAt=5000,LastDeliveredAt=7000,Confidence=.9,Radius=40;std::string TravelMode="on foot";vector PredictedPosition={1000,10,1000},Velocity={0,0,4};bool HasVelocity=true;}; +struct TraceFlags{static constexpr int WORLD=1,ENTS=2;};struct TraceBox{vector Start,End,Mins,Maxs;int Flags=0;}; +struct NativeWorld{int traces=0;float fraction=1;float TraceMove(TraceBox* t,void*){traces++;assert(t->Flags==3&&t->Mins.y==-.9f);return fraction;}}; +struct BaseRoad{float width=6;Arraypoints={{900,10,1300},{1100,10,1300}};float GetWidth(){return width;}void GetPoints(Array&p){p=points;}}; +struct RoadManager{Arrayroads;int queries=0;void GetRoadsInAABB(vector low,vector high,Array&out){queries++;assert(high.x-low.x==200&&high.z-low.z==200&&high.y-low.y==2000);out=roads;}}; +struct SCR_AIWorld{RoadManager manager;bool available=true;RoadManager*GetRoadNetworkManager(){return available?&manager:nullptr;}}; +struct Game{NativeWorld world;SCR_AIWorld ai;NativeWorld*GetWorld(){return &world;}SCR_AIWorld*GetAIWorld(){return &ai;}}game;Game*GetGame(){return &game;} +struct Replication{static inline bool server=true;static bool IsServer(){return server;}}; +struct ERR_Actors{static inline bool steep=false;static vector Ground(vector p){p.y=10;if(steep&&p.x>1000)p.y=11;return p;}}; +struct ChimeraWorldUtils{static inline bool water=false;static bool TryGetWaterSurfaceSimple(NativeWorld*,vector){return water;}}; + +struct ERR_SettlementSafety{static inline bool clear=true;static inline int calls=0;bool OutsideSettlements(vector){calls++;return clear;}}; +struct ERR_ConvoyPlacement{static inline bool clear=true;static inline int calls=0;static inline float heading=0;bool Clear(vector p,float h){calls++;heading=h;assert(p.y==10);return clear;}}; +''' + s + r''' +int main(){ + using Rules=ERR_HQMountedInterceptRules;ERR_HQBelief belief;SCR_AIGroup group; + assert(Rules::Eligible(&belief,10000)); + ERR_ConvoyInterceptOrder order;order.Token=400001;order.Group=&group;order.SourceId="original-convoy";order.VehicleIndex=0;order.Position={1000,10,1300};order.IssuedAt=8000;order.ExpiresAt=125000;order.ObservedAt=5000;order.BeliefId=3;order.BeliefRevision=2;order.ReportId=1; + vector frozen=order.Position;assert(Rules::Current(&order,&belief,10000));belief.PredictedPosition={1050,10,1000};assert(Rules::Current(&order,&belief,11000)&&order.Position==frozen);belief.PredictedPosition={1000,10,1000}; + for(int fault=0;fault<21;fault++){auto b=belief;auto o=order;float now=10000; + if(fault==0)o.Token=400000; + if(fault==1)o.Token=400257; + if(fault==2)o.Group=nullptr; + if(fault==3)o.SourceId=""; + if(fault==4)o.VehicleIndex=1; + if(fault==5)o.BeliefId++; + if(fault==6)o.BeliefRevision++; + if(fault==7)o.ReportId++; + if(fault==8)o.ObservedAt++; + if(fault==9)o.IssuedAt=6000; + if(fault==10)o.IssuedAt=11000; + if(fault==11)o.ExpiresAt=125001; + if(fault==12)o.ExpiresAt=10000; + if(fault==13)o.IssuedAt=NAN; + if(fault==14)o.Position.x=NAN; + if(fault==15)b.HasVelocity=false; + if(fault==16)b.SampleCount=1; + if(fault==17)b.LastDeliveredAt=10001; + if(fault==18)b.TravelMode="aircraft"; + if(fault==19)now=38001; + if(fault==20)b.ObservedAt=-1; + assert(!Rules::Current(&o,&b,now));} + order.VehicleIndex=3;assert(Rules::Current(&order,&belief,38000));order.VehicleIndex=5;assert(Rules::Current(&order,&belief,38000)); + assert(!Rules::Eligible(&belief,50001));assert(!Rules::Eligible(nullptr,10000)); + Array reserved;vector result;BaseRoad road;game.ai.manager.roads={&road}; + assert(Rules::Select(&belief,10000,reserved,result)&&result==vector(1000,10,1300));assert(ERR_ConvoyPlacement::calls==1&&ERR_SettlementSafety::calls==1);assert(std::abs(ERR_ConvoyPlacement::heading-90)<.001); + road.width=5.99;assert(!Rules::Select(&belief,10000,reserved,result)&&result==vector::Zero);road.width=6; + int placementCalls=ERR_ConvoyPlacement::calls;ERR_SettlementSafety::clear=false;assert(!Rules::Select(&belief,10000,reserved,result)&&result==vector::Zero&&ERR_ConvoyPlacement::calls==placementCalls);ERR_SettlementSafety::clear=true; + ERR_ConvoyPlacement::clear=false;assert(!Rules::Select(&belief,10000,reserved,result)&&result==vector::Zero);ERR_ConvoyPlacement::clear=true; + road.points={{900,20,1300},{1100,20,1300}};placementCalls=ERR_ConvoyPlacement::calls;assert(!Rules::Select(&belief,10000,reserved,result)&&ERR_ConvoyPlacement::calls==placementCalls);road.points={{900,10,1300},{1100,10,1300}}; + reserved.Insert({1000,10,1300});assert(!Rules::Select(&belief,10000,reserved,result));reserved.clear(); + game.ai.manager.roads.resize(129,&road);assert(!Rules::Select(&belief,10000,reserved,result));game.ai.manager.roads={&road}; + auto unknown=belief;unknown.HasVelocity=false;int queries=game.ai.manager.queries;assert(!Rules::Select(&unknown,10000,reserved,result)&&game.ai.manager.queries==queries); + Replication::server=false;assert(!Rules::Select(&belief,10000,reserved,result)&&game.ai.manager.queries==queries);Replication::server=true; + float score,heading;assert(Rules::FromPolyline({{900,10,1300},{1100,10,1300}},belief.Position,belief.Velocity,{1000,10,1350},reserved,result,score,heading)&&result.z==1300); + assert(!Rules::Ahead(belief.Position,belief.Velocity,{1000,10,1299}));assert(!Rules::Ahead(belief.Position,belief.Velocity,{1000,10,1901})); + Array oversized(1025,{1000,10,1300});assert(!Rules::FromPolyline(oversized,belief.Position,belief.Velocity,{1000,10,1350},reserved,result,score,heading)); +} +''') +print('PASS: actual mounted intercept eligibility/order chronology, fixed destinations, native road selection bounds, width, segment heading, clearance and settlement delegation; no native driving claim')