--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQMountedIntercept.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQMountedIntercept.c @@ -106,4 +106,124 @@ result = grounded; return true; } + + // Keep both escorts on one real polyline. Horizontal arc length determines + // placement; interpolation retains each road segment's height and heading. + static bool PairFromPolyline(array points, vector origin, vector velocity, vector seed, array reserved, out vector first, out vector second, out float score, out float firstHeading, out float secondHeading) + { + first = vector.Zero; second = vector.Zero; score = 1e20; firstHeading = 0; secondHeading = 0; + if (!points || points.Count() < 2 || points.Count() > 1024 || !ERR_ForceRules.FiniteVector(seed)) return false; + if (!ERR_ForceRules.FiniteVector(origin) || !ERR_HQPursuitRules.Heading(velocity, true) || !reserved || reserved.Count() > 128) return false; + foreach (vector point : points) if (!ERR_ForceRules.FiniteVector(point)) return false; + velocity[1] = 0; + float total; float anchor; float nearest = 1e20; + float along; + vector projected; + 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 (!ERR_ForceRules.Finite(length)) return false; + 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 (!found || ERR_HQRoadIntercept.Better(distance, candidate, nearest, projected)) + { + projected = candidate; nearest = distance; anchor = total + length * fraction; + along = vector.Dot(delta.Normalized(), velocity.Normalized()); found = true; + } + total += length; + if (!ERR_ForceRules.Finite(total)) return false; + } + if (!found || nearest > 100 || total < 40) return false; + float span = Math.Clamp(total, 40, 60); + float start = anchor - span * 0.5; + // Longitudinal roads extend the pair ahead of the projection, so the first + // point at the 300m planning boundary is not shifted behind that boundary. + if (along >= 0.25) start = anchor; + else if (along <= -0.25) start = anchor - span; + start = Math.Clamp(start, 0, total - span); + float finish = start + span; + float travelled; + vector a, b; + float aHeading; float bHeading; + bool haveFirst; bool haveSecond; + for (int j = 1; j < points.Count(); j++) + { + vector segment = points[j] - points[j - 1]; segment[1] = 0; + float segmentLength = segment.Length(); + if (segmentLength < 0.01) continue; + if (!haveFirst && start <= travelled + segmentLength) + { + a = vector.Lerp(points[j - 1], points[j], Math.Clamp((start - travelled) / segmentLength, 0, 1)); + aHeading = Math.Atan2(segment[0], segment[2]) * Math.RAD2DEG; haveFirst = true; + } + if (!haveSecond && finish <= travelled + segmentLength) + { + b = vector.Lerp(points[j - 1], points[j], Math.Clamp((finish - travelled) / segmentLength, 0, 1)); + bHeading = Math.Atan2(segment[0], segment[2]) * Math.RAD2DEG; haveSecond = true; + } + if (haveFirst && haveSecond) break; + travelled += segmentLength; + } + if (!haveFirst || !haveSecond || vector.DistanceXZ(a, b) < 40) return false; + if (vector.DistanceXZ(seed, a) > 100 || vector.DistanceXZ(seed, b) > 100) return false; + if (!Ahead(origin, velocity, a) || !Ahead(origin, velocity, b)) return false; + if (!ERR_HQRoadIntercept.Unreserved(a, reserved) || !ERR_HQRoadIntercept.Unreserved(b, reserved)) return false; + // Canonical order does not depend on native road enumeration direction. + if (ERR_HQRoadIntercept.Better(0, b, 0, a)) + { + vector swap = a; a = b; b = swap; + float swapHeading = aHeading; aHeading = bHeading; bHeading = swapHeading; + } + first = a; second = b; score = nearest; firstHeading = aHeading; secondHeading = bHeading; + return true; + } + static bool SelectPair(ERR_HQBelief belief, float now, array reserved, out vector first, out vector second) + { + first = vector.Zero; second = 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 bestFirst, bestSecond; + float bestScore = 1e20; + float bestFirstHeading; float bestSecondHeading; + 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 a, b; + float score; float aHeading; float bHeading; + if (!PairFromPolyline(points, belief.PredictedPosition, velocity, seed, reserved, a, b, score, aHeading, bHeading)) continue; + if (!found || ERR_HQRoadIntercept.Better(score, a, bestScore, bestFirst)) + { bestFirst = a; bestSecond = b; bestScore = score; bestFirstHeading = aHeading; bestSecondHeading = bHeading; found = true; } + } + if (!found) return false; + vector groundedFirst = ERR_Actors.Ground(bestFirst); + vector groundedSecond = ERR_Actors.Ground(bestSecond); + if (!ERR_HQPursuitRules.PositionValid(groundedFirst) || !ERR_HQPursuitRules.PositionValid(groundedSecond)) return false; + if (Math.AbsFloat(groundedFirst[1] - bestFirst[1]) > 2 || Math.AbsFloat(groundedSecond[1] - bestSecond[1]) > 2) return false; + if (vector.DistanceXZ(groundedFirst, groundedSecond) < 40) return false; + ERR_SettlementSafety settlements = new ERR_SettlementSafety(); + if (!settlements.OutsideSettlements(groundedFirst) || !settlements.OutsideSettlements(groundedSecond)) return false; + ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement(); + if (!placement.Clear(groundedFirst, bestFirstHeading) || !placement.Clear(groundedSecond, bestSecondHeading)) return false; + first = groundedFirst; second = groundedSecond; + return true; + } } --- /dev/null +++ b/tests/test_hq_mounted_intercept_pair.py @@ -0,0 +1,133 @@ +"""Actual paired road geometry and bounded native survey; native boundaries are doubled.""" +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_HQMountedIntercept.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{static inline int reads=0;float width=6;Arraypoints={{900,10,1300},{1100,10,1300}};float GetWidth(){return width;}void GetPoints(Array&p){reads++;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 float rightHeight=10;static vector Ground(vector p){p.y=p.x>1000?rightHeight:10;return p;}}; +struct ChimeraWorldUtils{static inline bool water=false;static bool TryGetWaterSurfaceSimple(NativeWorld*,vector){return water;}}; + +struct ERR_SettlementSafety{static inline bool clear=true,rightClear=true;static inline int calls=0;bool OutsideSettlements(vector p){calls++;return clear&&(p.x<=1000||rightClear);}}; +struct ERR_ConvoyPlacement{static inline bool clear=true,rightClear=true,water=false;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&&!water&&(p.x<=1000||rightClear);}}; +''' + s + r''' +int main(){ + using Rules=ERR_HQMountedInterceptRules; + ERR_HQBelief belief;Array reserved;vector a={1,2,3},b={4,5,6};float score=0,ha=0,hb=0; + vector origin={1000,10,1000},velocity={0,0,4},seed={1000,10,1300}; + auto pair=[&](Array p){return Rules::PairFromPolyline(p,origin,velocity,seed,reserved,a,b,score,ha,hb);}; + auto empty=[&](){assert(a==vector::Zero&&b==vector::Zero);}; + // Real arc interpolation over a90-degree bend, not straight interpolation + // between the road's endpoints (which would put both escorts off the road). + assert(pair({{900,10,1300},{1000,10,1300},{1000,10,1400}})); + assert(a==vector(970,10,1300)&&b==vector(1000,10,1330)); + assert(std::abs(ha-90)<.001&&std::abs(hb)<.001); + assert(vector::DistanceXZ(a,b)>42&&vector::DistanceXZ(a,b)<43); + // Arc span alone is insufficient on a bend-back/hairpin. + assert(!pair({{970,10,1300},{1000,10,1300},{1000,10,1301},{970,10,1301}}));empty(); + assert(!pair({{980,10,1300},{1019.9f,10,1300}}));empty(); + assert(pair({{980,10,1300},{1020,10,1300}})&&vector::DistanceXZ(a,b)==40); + // A road aligned with travel must not push a partner behind the300m floor. + assert(pair({{1000,10,1250},{1000,10,1450}})); + assert(a==vector(1000,10,1300)&&b==vector(1000,10,1360)); + assert(pair({{1000,10,1450},{1000,10,1250}})); + assert(a==vector(1000,10,1300)&&b==vector(1000,10,1360)); + // Actual sloped segment retains height for subsequent bridge validation. + assert(pair({{900,5,1300},{1100,15,1300}})); + assert(a==vector(970,8.5,1300)&&b==vector(1030,11.5,1300)); + // Degenerate segments are harmless but a wholly degenerate road has no span. + assert(pair({{900,10,1300},{900,10,1300},{1100,10,1300}})); + assert(!pair({{1000,10,1300},{1000,10,1300}}));empty(); + assert(!pair({{900,10,1300},{NAN,10,1300},{1100,10,1300}}));empty(); + assert(!pair(Array(1025,{1000,10,1300})));empty(); + assert(!pair({}));empty();assert(!pair({{1000,10,1300}}));empty(); + auto good=Array{{900,10,1300},{1100,10,1300}}; + velocity={0,0,0};assert(!pair(good));empty();velocity={0,0,4}; + seed.x=NAN;assert(!pair(good));empty();seed={1000,10,1300}; + origin.x=NAN;assert(!pair(good));empty();origin={1000,10,1000}; + reserved={{970,10,1380}};assert(pair(good)); + reserved={{970,10,1379.99f}};assert(!pair(good));empty(); + reserved={{NAN,10,1380}};assert(!pair(good));empty(); + reserved=Array(129,{10000,10,10000});assert(!pair(good));empty();reserved.clear(); + for(float z:{1290.f,1901.f,700.f}){seed.z=z;assert(!pair({{900,10,z},{1100,10,z}}));empty();}seed.z=1300; + BaseRoad road;road.points=good;game.ai.manager.roads={&road}; + int q=game.ai.manager.queries; + assert(Rules::SelectPair(&belief,10000,reserved,a,b));assert(game.ai.manager.queries==q+1); + assert(a==vector(970,10,1300)&&b==vector(1030,10,1300)); + assert(ERR_SettlementSafety::calls==2&&ERR_ConvoyPlacement::calls==2); + auto select=[&](){a={1,2,3};b={4,5,6};bool ok=Rules::SelectPair(&belief,10000,reserved,a,b);if(!ok)empty();return ok;}; + // Second position cannot escape settlement, water/collision or bridge checks. + int pc=ERR_ConvoyPlacement::calls;ERR_SettlementSafety::rightClear=false;assert(!select());assert(ERR_ConvoyPlacement::calls==pc);ERR_SettlementSafety::rightClear=true; + pc=ERR_ConvoyPlacement::calls;ERR_ConvoyPlacement::rightClear=false;assert(!select());assert(ERR_ConvoyPlacement::calls==pc+2);ERR_ConvoyPlacement::rightClear=true; + pc=ERR_ConvoyPlacement::calls;ERR_ConvoyPlacement::water=true;assert(!select());assert(ERR_ConvoyPlacement::calls==pc+1);ERR_ConvoyPlacement::water=false; + pc=ERR_ConvoyPlacement::calls;ERR_Actors::rightHeight=7;assert(!select());assert(ERR_ConvoyPlacement::calls==pc);ERR_Actors::rightHeight=10; + road.points={{900,20,1300},{1100,20,1300}};assert(!select());road.points=good; + // Reject a pair assembled from two short unrelated roads, even when their + // endpoints could have formed a geometrically spaced chord. + BaseRoad left,right;left.points={{960,10,1300},{980,10,1300}};right.points={{1020,10,1300},{1040,10,1300}};game.ai.manager.roads={&left,&right};assert(!select()); + game.ai.manager.roads={&road};road.width=5.99;assert(!select());road.width=NAN;assert(!select());road.width=6; + // All pre-query gates remain cheap; one permitted pair call is one query. + q=game.ai.manager.queries;belief.HasVelocity=false;assert(!select());belief.HasVelocity=true; + Replication::server=false;assert(!select());Replication::server=true; + reserved.resize(129);assert(!select());reserved.clear(); + assert(game.ai.manager.queries==q); + game.ai.available=false;assert(!select());game.ai.available=true;assert(game.ai.manager.queries==q); + // Road count bound refuses before any point arrays are read. + game.ai.manager.roads=Array(129,&road);int reads=BaseRoad::reads;assert(!select());assert(BaseRoad::reads==reads); + game.ai.manager.roads=Array(128,&road);assert(select());assert(BaseRoad::reads==reads+128); + // Total4096 segment allowance includes irrelevant/degenerate vertices; an + // earlier valid pair cannot leak through once later roads exhaust the cap. + BaseRoad big,tail;big.points=Array(1024,{900,10,1300});big.points.back()={1100,10,1300};tail.points=Array(5,{900,10,1300});tail.points.back()={1100,10,1300}; + game.ai.manager.roads={&big,&big,&big,&big,&tail};assert(select()); + tail.points.insert(tail.points.begin(),{900,10,1300});pc=ERR_ConvoyPlacement::calls;assert(!select());assert(ERR_ConvoyPlacement::calls==pc); + // Pair failure still permits unchanged singleton selection in a separate + // call; this explicitly consumes the caller's second survey budget. + road.points={{995,10,1300},{1005,10,1300}};game.ai.manager.roads={&road};q=game.ai.manager.queries; + assert(!select());vector singleton;assert(Rules::Select(&belief,10000,reserved,singleton));assert(singleton==vector(1000,10,1300)&&game.ai.manager.queries==q+2); +} + +''') +print('PASS: actual paired arc geometry,40m/80m spacing, separate original polylines, both-position validation and128road/4096segment budget; no native driving proof')