--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c @@ -1408,6 +1408,8 @@ Retask(now); Investigate(now, globalLimit); assigned = HuntingAssigned(); + // Retask/investigation and cleanup above have settled commitments for this pass. + int totalAssigned = Assigned(); array reserved = {}; int first = -1; int second = -1; @@ -1417,7 +1419,7 @@ } // Strongest delivered track first; finite scans (64 beliefs,128 own units). array considered = {}; - for (int pass = 0; pass < m_PlanBeliefs.Count() && assigned < limit && Assigned() < globalLimit; pass++) + for (int pass = 0; pass < m_PlanBeliefs.Count() && assigned < limit && totalAssigned < globalLimit; pass++) { ERR_HQBelief belief; for (int i = 0; i < m_PlanBeliefs.Count(); i++) @@ -1433,7 +1435,7 @@ if (belief.Kind == ERR_HQMemoryKind.ALARM && AlarmCommitted()) continue; ERR_HQHuntWindow hunt = Hunt(belief.BeliefId); if (!hunt || now < hunt.NextHunt) continue; - for (int slot = 0; slot < limit && assigned < limit && Assigned() < globalLimit; slot++) + for (int slot = 0; slot < limit && assigned < limit && totalAssigned < globalLimit; slot++) { // One mobile search squad for uncertain sound; never empty objectives for it. if ((belief.Kind == ERR_HQMemoryKind.GUNFIRE || belief.Kind == ERR_HQMemoryKind.ALARM) && slot > 0) break; @@ -1489,6 +1491,7 @@ if (best.HomeGuard) m_HomeAvailable.Set(best.HomeId, HomeAvailable(best) - 1); hunt.NextHunt = Math.Max(hunt.NextHunt, task.ExpiresAt + 180000); assigned++; + totalAssigned++; } } } --- /dev/null +++ b/tests/test_hq_planner_budget.py @@ -0,0 +1,104 @@ +"""Actual planner selection/commit body: identical orders with one local count scan.""" +import os,re +from pathlib import Path +from policy_harness import ROOT,body,run +P=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c')) +s=body(P,'protected void Assign(') +# Execute the complete final selection/commit loop. The preceding cleanup, +# Retask and Investigate boundaries are represented by pre-existing commitments. +start=s.index(' assigned = HuntingAssigned();') +code=s[start:s.rfind('}')] +# In the reference variant, restore only the old repeated-count loop guards. +legacy=code.replace(' int totalAssigned = Assigned();','').replace('totalAssigned < globalLimit','Assigned() < globalLimit').replace(' totalAssigned++;','') +assigned=body(P,'protected int Assigned(').replace('protected ','').replace('int Assigned()', 'int ActualAssigned()') +def adapt(s): + s=s.replace('array','Arr').replace('array','Arr').replace('vector destination','Vec destination').replace('vector position','Vec position') + s=s.replace('foreach (ERR_HQUnit unit : m_Units)','for (ERR_HQUnit* unit : m_Units)') + for cls in ('ERR_HQBelief','ERR_HQHuntWindow','ERR_HQUnit','ERR_HQRoute','ERR_HQTask'): + s=re.sub(r'\b'+cls+r' (\w+)',cls+r'* \1',s) + s=re.sub(r'(ERR_HQ(?:Belief|Unit|Route)\* \w+);',r'\1=nullptr;',s) + s=s.replace('int total;', 'int total=0;') + s=s.replace('bool roadIntercept;', 'bool roadIntercept=false;').replace('int bestId;', 'int bestId=0;') + for name in ('unit','belief','candidate','hunt','best','bestRoute','task'): + s=s.replace(name+'.',name+'->') + s=s.replace('unit->Group.GetOrigin()', 'unit->Group->GetOrigin()').replace('belief->Report.Snapshot()', 'belief->Report->Snapshot()') + s=s.replace('new ERR_HQTask()', 'NewTask()').replace('null','nullptr').replace('nullptrptr','nullptr') + for name in ('ERR_HQPursuitRules','ERR_HQPursuitRole','ERR_HQMemoryKind','ERR_HQRoadIntercept','Math'): + s=s.replace(name+'.',name+'::') + s=s.replace('vector.DistanceXZ','Distance').replace('string.Format','Format').replace('ERR_HQPursuitRole role','int role') + return s +run(r''' +#include +#include +#include +#include +#include +#include +#include +using string=std::string; +template struct Arr:std::vector{using std::vector::vector;int Count()const{return this->size();}void Insert(T t){this->push_back(t);}bool Contains(T t){return std::find(this->begin(),this->end(),t)!=this->end();}}; +struct Vec{float x=0;bool operator==(Vec b)const{return x==b.x;}}; +float Distance(Vec a,Vec b){return std::abs(a.x-b.x);} +struct Math{static float Max(float a,float b){return std::max(a,b);}}; +templatestring Format(string s,T...){return s;} +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_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 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;} + static Vec Objective(Vec p,Vec,bool,float,float,float,int role,int slot){return {p.x+role*10+slot};} + static bool CanCommit(bool group,bool eligible,bool busy,bool guard,int available,int assigned,int limit){return group&&eligible&&!busy&&(!guard||available>1)&&assigned& r,Vec& p){p.x=b->PredictedPosition.x+100+r.Count();return b->BeliefId%2==0;}}; +struct Watch{bool ArmedSearchBlocked(int source,float){return source%11==0;}}; +struct Coverage{bool allow=true;Arr selections;bool Select(Vec p,float,Vec,float,Vec& result,Arr& reserved){if(!allow)return false;result.x=p.x+reserved.Count()*25;selections.Insert(result);return true;}}; +struct Network{ERR_HQRoute routes[129];ERR_HQRoute* Route(int radio,bool){return radio%7?&routes[radio]:nullptr;}}; +struct Journal{int writes=0,seq=0;int Message(){return ++seq;}void Write(string,string,int){writes++;}}; +struct Homes{std::map v;void Set(int id,int n){v[id]=n;}int Get(int id){return v[id];}}; +struct Harness { + Arr m_Units;Arr m_PlanBeliefs;Watch m_Watch;Coverage m_Coverage;Network m_Network;Journal m_Journal;Homes m_HomeAvailable; + std::map hunts;std::vector> tasks; + int calls=0,visits=0,hunting=0;ERR_HQTask* NewTask(){tasks.push_back(std::make_unique());return tasks.back().get();} + COUNT + int Assigned(){calls++;visits+=m_Units.Count();return ActualAssigned();} + int HuntingAssigned(){return hunting;} + int HomeAvailable(ERR_HQUnit* u){return m_HomeAvailable.Get(u->HomeId);} + ERR_HQHuntWindow* Hunt(int id){return &hunts[id];} + bool AlarmCommitted(){for(auto u:m_Units)if(u->PendingTask&&u->PendingTask->BeliefId==63)return true;return false;} + Vec Containment(ERR_HQBelief*,Vec v){return v;} + void ReserveUnit(ERR_HQUnit* u,Arr& reserved,int& first,int& second){if(!u->Assignment&&!u->PendingOrder)return;reserved.Insert(u->PlanPosition);int b=u->Id%2;if(first<0)first=b;else if(first!=b&&second<0)second=b;} + void Current(int globalLimit,int limit,float now){int assigned=0,radius=10000,roadQueries=0; CURRENT } + void Legacy(int globalLimit,int limit,float now){int assigned=0,radius=10000,roadQueries=0; LEGACY } +}; +struct Setup{Harness h;ERR_HQUnit units[128];struct Group groups[128];ERR_HQReport reports[64];ERR_HQBelief beliefs[64]; + Setup(int busy,int variant){ + for(int i=0;i<128;i++){auto&u=units[i];u.Id=i+1;u.Radio=i+1;u.ForceSourceId=i+1;u.Group=&groups[i];u.PlanPosition={float(i+1)};u.HomeGuard=(i%5==0);u.HomeId=i%3;h.m_HomeAvailable.Set(u.HomeId,20);u.PlanEligible=variant!=1;u.PlanFighting=(i%17==0);if(iBeliefId==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(){ + for(int variant=0;variant<4;variant++)for(int global:{0,1,2,3,8,16})for(int busy:{0,1,2,5,16})for(int hunting:{0,1,3}){ + Setup current(busy,variant),legacy(busy,variant);current.h.hunting=legacy.h.hunting=std::min(busy,hunting);current.h.Current(global,std::min(global,3),1000);legacy.h.Legacy(global,std::min(global,3),1000);Equal(current,legacy);assert(current.h.calls<=1);assert(current.h.ActualAssigned()<=std::max(busy,global));} + Setup current(0,1),legacy(0,1);current.h.Current(16,3,1000);legacy.h.Legacy(16,3,1000);Equal(current,legacy);assert(current.h.visits==128);assert(legacy.h.visits>10000); +} +'''.replace('COUNT',adapt(assigned)).replace('CURRENT',adapt(code)).replace('LEGACY',adapt(legacy))) +assert s.index('int totalAssigned = Assigned();')>s.index('Investigate(now, globalLimit);')>s.index('Retask(now);') +print('PASS: actual full planner selection/commit body, 360 equivalence scenarios, orders/reservations/cooldowns identical; one128-unit scan vs >10000 repeated unit visits; no FPS claim')