Files
EscapistsReintroduced/tests/test_roadblock_background.py
T

169 lines
15 KiB
Python

"""Run the shipped initial/background scheduler with fake native work and clock."""
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c'
s='\n'.join(body(p,signature) for signature in ('protected ERR_PrepareResult Finish(', 'ERR_PrepareResult Prepare(', 'protected void SearchStep(', 'protected void BackgroundStep()', 'void Stop()'))
s=re.sub(r'#ifdef WORKBENCH.*?#endif','',s,flags=re.S)
s=s.replace('protected ', '').replace('ERR_ScenarioDefinition definition', 'Definition* definition')
s=s.replace('array<vector> positions = {};', 'Array<int> positions;').replace('array<ref ERR_RoadblockProp> covers = {};', 'Array<int> covers;')
s=s.replace('GetGame().GetCallqueue().CallLater(BackgroundStep, 200, true)', 'queue.Schedule(200)').replace('GetGame().GetCallqueue().Remove(BackgroundStep)', 'queue.Remove()')
s=s.replace('GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High)', 'Bounds(m_Low, m_High)').replace('GetGame().GetPlayerManager().GetPlayerCount()', 'players')
s=s.replace('m_Mode.Settings().Get("roadblockCount")', 'm_Mode->target').replace('m_Mode.Forces().Count()', 'm_Mode->ledger')
s=s.replace('m_Mode.', 'm_Mode->').replace('System.GetTickCount()', 'Now()').replace('Replication.IsServer()', 'server').replace('ERR_GameMode.Get()', 'current')
s=s.replace('ERR_PrepareResult.', 'Result::').replace('ERR_Phase.', 'Phase::').replace('ERR_RoadblockRules.', 'Rules::').replace('ERR_TerrainSites.Bounds', 'ValidBounds').replace('ERR_HQ.Event','Event').replace('Math.Max','std::max').replace('null','nullptr')
rules='\n'.join(body(p,sig) for sig in ('static int SearchMilliseconds(', 'static int SearchAttempts('))
run(r'''
#include <cassert>
#include <algorithm>
#include <string>
#include <vector>
using string=std::string;
namespace Result{enum Value{FAILED=-1,PENDING,READY};}using ERR_PrepareResult=Result::Value;
namespace Phase{enum{PREPARING,PRISON,ESCAPED,ENDED};}
namespace Rules{
'''+rules+r'''
}
int clockMs=0,players=1;bool server=true;
int Now(){return clockMs;}bool GetGame(){return true;}void Bounds(int&,int&){}bool ValidBounds(int,int){return true;}
template<class...T>void PrintFormat(T...){}void Event(string,string){}
template<class T>struct Array:std::vector<T>{int Count(){return this->size();}void Clear(){this->clear();}};
struct Queue{int scheduled=0,removed=0;bool active=false;void Schedule(int ms){assert(ms==200);scheduled++;active=true;}void Remove(){removed++;active=false;}};
struct Mode{int target=30,ledger=0,phase=Phase::PRISON;bool active=true;int Phase(){return phase;}bool Active(){return active;}};Mode* current=nullptr;
struct Definition{};
struct Mining{bool stopped=false;void Stop(){stopped=true;}};
struct Scheduler{
Mode* m_Mode;Definition* m_Definition=nullptr;Queue queue;Mining m_Mining;
bool m_BuildGroup=false;void BuildPost(){if(m_Mode->ledger>=250)CancelPost();}void CancelPost(){m_BuildGroup=false;}
bool m_Begun=false,m_Ready=false,m_Stopped=false,m_HaveCandidate=false,m_Background=false;
int m_Target=0,m_Attempts=0,m_Started=0,m_CandidateStarted=0,m_Candidates=0,m_Planned=0;
int m_NextSearch=0,m_NextReport=0,m_BackgroundSteps=0,m_BackgroundWork=0,m_MaxBackgroundStep=0,m_Low=0,m_High=0;
Array<int>m_Loaded,m_Positions,m_Groups;
int candidateCost=0,candidateCalls=0,planCalls=0,spawnCalls=0;bool accept=false,spawn=true,roster=true;int plan=2;
string Status(){return "status";}bool LoadRoster(){m_Loaded.push_back(1);return roster;}
bool Candidate(Definition*){candidateCalls++;clockMs+=candidateCost;if(accept){m_Candidates++;m_CandidateStarted=Now();}return accept;}
int Plan(Array<int>&,Array<int>&){planCalls++;return plan;}
bool SpawnPost(Array<int>&,Array<int>&){spawnCalls++;if(spawn){m_Groups.push_back(1);m_Positions.push_back(1);m_NextSearch=Now()+2000;}return spawn;}
'''+s+r'''
};
int main(){
Mode mode;current=&mode;Definition definition;Scheduler s;s.m_Mode=&mode;
// No candidates in the entire initial window: admission yields, never gives up.
assert(s.Prepare(&definition)==Result::PENDING&&s.candidateCalls==8);
clockMs=12000;assert(s.Prepare(&definition)==Result::READY&&s.m_Background&&s.queue.active&&s.m_Definition==&definition);
assert(s.m_Loaded.Count()==1&&s.m_Positions.Count()==0&&s.queue.scheduled==1);
assert(s.Prepare(&definition)==Result::READY&&s.queue.scheduled==1);
// Empty, full ledger and inactive/initializing cases cannot do terrain work.
players=0;s.BackgroundStep();assert(s.candidateCalls==8);
players=1;mode.ledger=250;s.BackgroundStep();assert(s.candidateCalls==8);mode.ledger=0;
mode.phase=Phase::PREPARING;s.BackgroundStep();assert(s.candidateCalls==8);mode.phase=Phase::ESCAPED;
// Native operations yield between samples if the cooperative time budget expires.
s.candidateCost=3;s.BackgroundStep();assert(s.candidateCalls==9&&s.m_MaxBackgroundStep==3);
s.candidateCost=0;s.BackgroundStep();assert(s.candidateCalls==17);
// Arbitrarily many exhausted attempts do not end background placement.
s.m_Attempts=50000;clockMs+=60000;s.BackgroundStep();assert(s.m_Background&&s.m_Attempts==50008);
s.accept=true;
// Candidate planning/physical creation occur on a separate callback.
s.BackgroundStep();assert(s.m_HaveCandidate&&s.planCalls==0);
s.plan=1;clockMs+=500;s.BackgroundStep();assert(s.m_HaveCandidate&&s.spawnCalls==0);
clockMs+=2000;s.BackgroundStep();assert(!s.m_HaveCandidate&&s.spawnCalls==0);s.plan=2;
// Failed creation remains pending. No partial count or allowance is consumed.
s.spawn=false;s.BackgroundStep();s.BackgroundStep();assert(s.m_Positions.Count()==0&&s.m_Background);s.spawn=true;
// Finish all 30 after prison unlock, at most one physical post per callback.
for(int i=0;i<30;i++){
s.BackgroundStep();assert(s.m_Positions.Count()==i);
s.BackgroundStep();assert(s.m_Positions.Count()==i+1);
int samples=s.candidateCalls;s.BackgroundStep();assert(s.candidateCalls==samples); // two-second spawn cooldown
clockMs+=2000;
}
assert(!s.m_Background&&!s.queue.active&&s.m_Loaded.Count()==0&&!s.m_Definition&&s.m_Ready);
int calls=s.spawnCalls;s.m_Groups.clear(); // killed/removed groups never refund the round's posts
s.BackgroundStep();assert(s.spawnCalls==calls&&s.Prepare(&definition)==Result::READY&&s.m_Positions.Count()==30);
// A newly full ledger must cancel a partial post instead of leaving actors stranded.
{Scheduler pending;pending.m_Mode=&mode;pending.Prepare(&definition);clockMs+=12000;pending.Prepare(&definition);
pending.m_BuildGroup=true;mode.ledger=250;pending.BackgroundStep();assert(!pending.m_BuildGroup);mode.ledger=0;}
// Attempt ceiling also hands off, without waiting for the wall-clock deadline.
clockMs=0;Scheduler attempts;attempts.m_Mode=&mode;attempts.Prepare(&definition);attempts.m_Attempts=300;
assert(attempts.Prepare(&definition)==Result::READY&&attempts.m_Background);
mode.active=false;attempts.BackgroundStep();assert(attempts.m_Stopped&&!attempts.queue.active&&attempts.m_Mining.stopped&&!attempts.m_Definition);
mode.active=true;Scheduler ended;ended.m_Mode=&mode;ended.Prepare(&definition);clockMs=12000;ended.Prepare(&definition);current=nullptr;
ended.BackgroundStep();assert(ended.m_Stopped&&!ended.queue.active);current=&mode;
mode.target=0;Scheduler disabled;disabled.m_Mode=&mode;assert(disabled.Prepare(&definition)==Result::READY&&!disabled.queue.active);
mode.target=30;Scheduler missing;missing.m_Mode=&mode;missing.roster=false;assert(missing.Prepare(&definition)==Result::READY&&!missing.queue.active);
}
''')
# Every background plan and physical transaction still uses the original safety checks.
assert 'if (!mesh || !PlayersClear()) return 0;' in body(p,'protected int Plan(')
assert 'if (!PlayersClear() || m_Mode.Forces().Count() >= 250 ||' in body(p,'protected bool SpawnPost(')
assert 'if (!PlayersClear() || m_Mode.Forces().Count() >= 250) { CancelPost(); return; }' in body(p,'protected void BuildPost(')
print('PASS: actual roadblock scheduler yields initial deadline/attempt cap to background, budgets samples, pauses empty/full worlds, completes 30 after unlock, preserves safety and never replenishes losses; teardown cancels work')
# Run actual staged physical creation and rollback, with native entities doubled.
s='\n'.join(body(p,sig) for sig in ('protected bool SpawnPost(', 'protected void CancelPost()', 'protected bool ShareProps()', 'protected void BuildPost()'))
s=s.replace('protected ', '').replace('array<vector> positions','Array<int>& positions').replace('array<ref ERR_RoadblockProp> covers','Array<Prop>& covers').replace('ERR_RoadblockProp prop', 'Prop prop')
s=s.replace('SCR_AIGroup.Cast','Node::Cast').replace('AIWaypoint.Cast','Node::Cast').replace('IEntity actor','Node* actor').replace('IEntity cover','Node* cover')
s=s.replace('foreach (IEntity ownedEntity : m_BuildSpawned)','for (Node* ownedEntity : m_BuildSpawned)').replace('vector point','int point').replace('vector coverPosition','int coverPosition')
s=s.replace('foreach (ERR_RoadblockProp plannedProp : covers)', 'for (Prop plannedProp : covers)')
s=s.replace('m_Forward.VectorToAngles()', '0').replace('m_Positions.Count().ToString()', 'std::to_string(m_Positions.Count())')
s=s.replace('GetGame().GetWorld().GetWorldTime()', 'Now()').replace('System.GetTickCount()', 'Now()').replace('null','nullptr')
s=s.replace('m_BuildGroup.', 'm_BuildGroup->').replace('m_BuildHold.', 'm_BuildHold->')
s=s.replace('IEntity entity', 'Node* entity').replace('RplComponent component;', 'Replica* component=nullptr;').replace('RplComponent component =', 'Replica* component =')
s=s.replace('RplComponent.Cast', 'Replica::Cast').replace('FindComponent(RplComponent)', 'FindComponent()').replace('RplId.Invalid()', '-1')
s=s.replace('entity.FindComponent', 'entity->FindComponent').replace('m_BuildSpawned[i].FindComponent', 'm_BuildSpawned[i]->FindComponent').replace('component.', 'component->')
for ns in ('ERR_Actors','ERR_Resources','ERR_ForceKind','ERR_RoadblockRules'):s=s.replace(ns+'.',ns+'::')
rule=body(p,'static int PropCount(')
run(r'''
#include <cassert>
#include <string>
#include <vector>
using string=std::string;
int Now(){return 100;}
template<class...T>void PrintFormat(T...){}
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;using std::vector<T>::operator=;int Count(){return this->size();}void Clear(){this->clear();}void Copy(Array<T>&other){*this=other;}void Insert(T value){this->push_back(value);}};
struct Replica{int id=1;bool streaming=true;static Replica* Cast(Replica*r){return r;}int Id(){return id;}void EnableStreaming(bool value){streaming=value;}};
struct Node{Replica replica;bool missing=false;Replica* FindComponent(){return missing?nullptr:&replica;}static Node* Cast(Node*n){return n;}void SetFaction(int){}void SetSpawnImmediately(bool){}void SetDeleteWhenEmpty(bool){}void SetPermanentLOD(int){}bool AddAIEntityToGroup(Node*){return true;}void SetCompletionRadius(int r){assert(r==25);}void AddWaypoint(Node*){}void ActivateAI(){}};
namespace ERR_Actors{int calls=0,failAt=0;Array<Node*> all;Node* Spawn(string,int,int=0){calls++;if(calls==failAt)return nullptr;auto*n=new Node;all.Insert(n);return n;}bool SetFaction(Node*,int){return true;}}
namespace ERR_Resources{string DefendWaypoint(){return "hold";}}
namespace ERR_ForceKind{int CHECKPOINT=1;}
struct Ledger{int count=0,registered=0;bool accept=true;int Count(){return count;}bool Register(string,int,int,int strength,void*,Node*,int){assert(strength==4);if(!accept)return false;registered++;return true;}};
struct Mode{Ledger ledger;int owned=0,groups=0;Ledger& Forces(){return ledger;}int EnemyFaction(){return 1;}void Own(Node*n){assert(n);owned++;}void RegisterGroup(Node*){groups++;}};
struct Placement{bool clear=true;bool Clear(int,void*){return clear;}};
struct Mining{int registered=0;void Register(Node*,Node*,int){registered++;}};
struct Prop{string Prefab="cover";int Position=5,Angles=0;};
namespace ERR_RoadblockRules{'''+rule+r'''}
struct Build{
Mode m_Mode;Placement m_Placement;Mining m_Mining;Node* m_BuildGroup=nullptr;Node* m_BuildHold=nullptr;
Array<int> m_BuildPositions,m_Positions;Array<Prop>m_BuildCovers;Array<Node*>m_BuildSpawned,m_Groups;Array<string>m_Roster={"one","two"};
int m_BuildActors=0,m_BuildCoverCount=0,m_Centre=10,m_Right=0,m_NextSearch=0,rolledBack=0;string COVER="cover";bool playersClear=true,coversClear=true;
bool PlayersClear(){return playersClear;}bool CoverClear(Prop){return coversClear;}
void Rollback(Array<Node*>&entities,Node*group){rolledBack+=entities.Count()+(group?1:0);}
'''+s+r'''
};
int main(){Array<int> positions={1,2,3,4};Array<Prop>covers(ERR_RoadblockRules::PropCount());const int pieces=4+covers.Count()+1;
{Build b;int start=ERR_Actors::calls;assert(b.SpawnPost(positions,covers));assert(ERR_Actors::calls==start+1);
for(int piece=0;piece<pieces;piece++){int calls=ERR_Actors::calls;b.BuildPost();assert(ERR_Actors::calls==calls+1&&b.m_Positions.Count()==0&&b.m_Mode.owned==0);}
auto shared=b.m_BuildSpawned;for(int i=4;i<4+covers.Count();i++)assert(shared[i]->replica.streaming);
b.BuildPost();for(int i=4;i<4+covers.Count();i++)assert(!shared[i]->replica.streaming);assert(!b.m_BuildGroup&&b.m_Positions.Count()==1&&b.m_Groups.Count()==1&&b.m_Mode.owned==pieces+1&&b.m_Mode.groups==1&&b.m_Mining.registered==1&&b.m_NextSearch==2100);
}
// A server-only or unregistered prop must never count as a complete roadblock.
for(int bad=4;bad<4+covers.Count();bad++)for(bool missing:{false,true}){
Build b;b.SpawnPost(positions,covers);for(int i=0;i<pieces;i++)b.BuildPost();
auto spawned=b.m_BuildSpawned;spawned[bad]->missing=missing;spawned[bad]->replica.id=-1;
b.BuildPost();assert(b.m_Positions.Count()==0&&!b.m_BuildGroup&&b.m_Mode.owned==0&&b.rolledBack==pieces+1);
for(int i=4;i<4+covers.Count();i++)assert(spawned[i]->replica.streaming); // reject before changing any streaming rule
}
// Player approaches at any stage: no registered post and every owned piece removed.
for(int stage=0;stage<=pieces;stage++){Build b;assert(b.SpawnPost(positions,covers));for(int i=0;i<stage;i++)b.BuildPost();b.playersClear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.m_Positions.Count()==0&&b.m_Mode.owned==0&&b.rolledBack==stage+1);}
// Any native entity creation failure rolls back rather than consuming a post.
for(int stage=0;stage<pieces+1;stage++){Build b;ERR_Actors::failAt=ERR_Actors::calls+stage+1;bool begun=b.SpawnPost(positions,covers);
if(stage==0)assert(!begun);else{for(int i=0;i<stage;i++)b.BuildPost();assert(b.rolledBack==stage);}
assert(!b.m_BuildGroup&&b.m_Positions.Count()==0&&b.m_Mode.owned==0);ERR_Actors::failAt=0;
}
{Build b;b.SpawnPost(positions,covers);b.m_Placement.clear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.rolledBack==1);}
{Build b;b.SpawnPost(positions,covers);for(int i=0;i<4;i++)b.BuildPost();b.coversClear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.rolledBack==5);}
{Build b;b.SpawnPost(positions,covers);for(int i=0;i<pieces;i++)b.BuildPost();b.m_Mode.ledger.accept=false;b.BuildPost();assert(!b.m_BuildGroup&&b.rolledBack==pieces+1&&b.m_Positions.Count()==0);}
{Build b;b.SpawnPost(positions,covers);b.BuildPost();b.CancelPost();assert(!b.m_BuildGroup&&b.rolledBack==2&&b.m_Mode.owned==0);}
for(auto*n:ERR_Actors::all)delete n;
}
''')
print('PASS: actual staged post creation spawns at most one entity per callback, commits only all four guards/all planned props/waypoint, and rolls back player approach, obstructions, spawn/replication/ledger failure and cancellation; all committed props bypass distance streaming')