Files
EscapistsReintroduced/tests/test_native_end_lifecycle.py
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
2026-09-20 16:42:52 +02:00

105 lines
7.8 KiB
Python

"""Actual native-end hook/shutdown/active bodies with engine boundaries substituted."""
from policy_harness import ROOT, body, run
SOURCE=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c'
def cpp(s):
s=s.replace('protected override ', '').replace('protected ', '')
for a,b in [('Replication.','Replication::'),('ERR_MissionRules.','Rules::'),('ERR_Phase.','Phase::'),('ERR_ObserverReason.','Observer::'),('SCR_AIGroup.','SCR_AIGroup::'),('ERR_Actors.','ERR_Actors::'),('super.OnGameModeEnd(', 'NativeEnd(')]:s=s.replace(a,b)
s=s.replace('array<int> connected = {};','std::vector<int> connected;').replace('foreach (','for (')
s=s.replace('SCR_PlayerController controller = SCR_PlayerController.Cast(', 'SCR_PlayerController* controller = SCR_PlayerController::Cast(').replace('controller.ERR_SetObserver(', 'controller->ERR_SetObserver(')
s=s.replace('GetGame().','GetGame()->').replace('GetCallqueue().','GetCallqueue()->').replace('GetPlayerManager().','GetPlayerManager()->')
return s
methods='\n'.join(cpp(body(SOURCE, signature)) for signature in ('bool Active(', 'protected override void OnGameModeEnd(', 'protected void StopServices('))
startGuard=body(SOURCE,'protected void StartScenario(').split('if (!m_Factions',1)[0].replace('protected ','')
run(r'''#include <cassert>
#include <string>
#include <vector>
#include <functional>
using ERR_Phase=int;using IEntity=int;using SCR_GameModeEndData=int;
struct string:std::string {using std::string::string;using std::string::operator=;bool IsEmpty(){return empty();}};
namespace Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION,VICTORY,DEFEAT};}
namespace Observer {enum {NONE};}
struct Replication {static bool server;static int bumps;static bool IsServer(){return server;}static void BumpMe(){bumps++;}};
bool Replication::server=true;int Replication::bumps=0;
struct Rules {static bool Terminal(int phase){return phase==Phase::VICTORY||phase==Phase::DEFEAT;}};
struct Queue {std::vector<int> removed;void Remove(int method){removed.push_back(method);}};
struct SCR_PlayerController {int releases=0;static SCR_PlayerController* Cast(SCR_PlayerController* p){return p;}void ERR_SetObserver(int){releases++;}};
struct Manager {SCR_PlayerController player;void GetPlayers(std::vector<int>& ids){ids={1};}SCR_PlayerController* GetPlayerController(int){return &player;}};
struct Game {Queue queue;Manager manager;Queue* GetCallqueue(){return &queue;}Manager* GetPlayerManager(){return &manager;}} game;
Game* GetGame(){return &game;}
struct SCR_AIGroup {static bool Cast(int e){return e==10;}};
struct ERR_Actors {static int deleted;static void DeleteSpawned(int e){assert(e==10);deleted++;}};int ERR_Actors::deleted=0;
struct Command {int stops=0;explicit operator bool()const{return true;}void Stop(){stops++;}};
struct PhaseEvent {int count=0,oldPhase=-1,newPhase=-1;std::function<void()> callback;void Invoke(int old,int next){count++;oldPhase=old;newPhase=next;if(callback)callback();}};
struct StopEvent {int count=0;std::function<void()> callback;void Invoke(){count++;if(callback)callback();}};
struct ForceBoundary {int clears=0;void Clear(){clears++;}};
struct Mode {
ForceBoundary m_Forces,m_ForceAgents;
int forceTicks=0;void TickForces(float){forceTicks++;}
bool m_Stopped=false,m_NativeEndHandled=false;
int m_Phase=Phase::PRISON,StartScenario=1,ServerTick=2,BindLocalObserver=3;
string m_EndReason;string m_PreparationError;
Command m_Command;PhaseEvent m_OnPhaseChanged;StopEvent m_OnStop;
std::vector<int> m_OwnedEntities={10,11};
int published=0,feedback=0,nativeEnds=0,receivedData=0,nativeWork=0,escapeSnapshot=77;
bool RefreshRadioFeedback(){feedback++;return true;}void PublishEscapeResults(){published++;}
void NativeEnd(int data){assert(!Replication::server||m_Stopped);nativeEnds++;receivedData=data;}
'''+methods+r'''
};
struct StartGuard {bool m_Stopped=false,m_NativeEndHandled=false;int m_Phase=Phase::PREPARING;string m_PreparationError;int nativeWork=0;
'''+cpp(startGuard)+r'''nativeWork++;}
};
int main(){
// Every unfinished phase closes before native listeners/restart, preserving data.
for(int phase=Phase::PREPARING;phase<=Phase::EXTRACTION;phase++) {
Mode m;m.m_Phase=phase;auto removed=game.queue.removed.size();int deleted=ERR_Actors::deleted;
m.OnGameModeEnd(314);
assert(m.m_Phase==Phase::DEFEAT&&m.m_Stopped&&m.m_NativeEndHandled&&!m.Active());
assert(m.m_OnPhaseChanged.count==1&&m.m_OnPhaseChanged.oldPhase==phase);
assert(m.m_OnStop.count==1&&m.m_Command.stops==1&&ERR_Actors::deleted==deleted+1);
assert(game.queue.removed.size()==removed+4&&game.queue.removed[removed]==m.BindLocalObserver&&game.queue.removed[removed+1]==m.StartScenario&&game.queue.removed[removed+2]==m.ServerTick&&game.queue.removed[removed+3]==m.BindLocalObserver);
assert(m.nativeEnds==1&&m.receivedData==314&&m.escapeSnapshot==77&&m.published==1&&m.m_Forces.clears==1&&m.m_ForceAgents.clears==1);
m.OnGameModeEnd(999);m.StopServices();
assert(m.nativeEnds==1&&m.receivedData==314&&m.m_OnStop.count==1&&m.m_OnPhaseChanged.count==1&&m.published==1);
}
// Existing successor terminal snapshots/reason are not replaced or republished.
for(int terminal:{Phase::VICTORY,Phase::DEFEAT}) {
Mode m;m.m_Phase=terminal;m.m_EndReason="original result";m.StopServices();
m.OnGameModeEnd(451);assert(m.m_Phase==terminal&&m.escapeSnapshot==77&&m.m_EndReason=="original result");
assert(m.m_OnPhaseChanged.count==0&&m.published==0&&m.m_OnStop.count==1&&m.nativeEnds==1&&m.receivedData==451);
}
// Reentrant end notification from successor listeners cannot schedule twice.
Mode reentrant;reentrant.m_OnPhaseChanged.callback=[&](){reentrant.OnGameModeEnd(2);};
reentrant.m_OnStop.callback=[&](){reentrant.OnGameModeEnd(3);reentrant.StopServices();};
reentrant.OnGameModeEnd(1);assert(reentrant.nativeEnds==1&&reentrant.receivedData==1&&reentrant.m_OnStop.count==1);
// Client closes local Active immediately, without mutating replicated phase/result.
Replication::server=false;Mode client;client.OnGameModeEnd(44);
assert(!client.Active()&&client.m_Phase==Phase::PRISON&&!client.m_Stopped&&client.published==0&&client.m_OnPhaseChanged.count==0&&client.nativeEnds==1);
Replication::server=true;
// An already dequeued preparation callback is independently inert after stop/end.
for(int fault=0;fault<4;fault++){
StartGuard g;if(fault==0)g.m_Stopped=true;if(fault==1)g.m_NativeEndHandled=true;if(fault==2)g.m_Phase=Phase::DEFEAT;if(fault==3)g.m_PreparationError="failed";
g.StartScenario();assert(g.nativeWork==0);
}
StartGuard normal;normal.StartScenario();assert(normal.nativeWork==1);
}
''')
source=SOURCE.read_text();hook=body(SOURCE,'protected override void OnGameModeEnd(')
assert hook.count('super.OnGameModeEnd(endData)')==1
assert hook.index('StopServices()') < hook.index('super.OnGameModeEnd(endData)')
assert 'Transition(' not in hook and 'EndGameMode(' not in hook
assert 'm_Extraction.' not in hook and 'EndGameData.Create' not in hook
assert 'if (!m_NativeEndHandled) EndGameMode(' in body(SOURCE,'bool Transition(')
probe=(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_NativeEndProbe.c').read_text()
assert probe.startswith('#ifdef WORKBENCH')
assert probe.count('ERR SMOKE COMPLETE:')==1 and probe.count('EndGameMode(m_ERREndData)')==1
assert 'super.OnGameModeEnd' not in probe
assert 'm_Phase = ERR_Phase.' not in probe
assert 'GetEndGameData() == m_ERREndData' in probe
assert 'StartScenario(); ServerTick(); StopServices();' in probe
assert 'erri-native-end-round.json' in probe
print('PASS: actual native-end shutdown, existing terminal snapshots, reentrancy, unchanged native end data and stale preparation guards')
bind=body(SOURCE,'protected void BindLocalObserver(')
assert bind.index('m_Stopped || m_NativeEndHandled') < bind.index('controller.ERR_StartObserverMonitor()')
assert hook.index('Remove(BindLocalObserver)') < hook.index('if (Replication.IsServer())')