Files
EscapistsReintroduced/tests/test_empty_server_restart.py
jakub e4ca37dfc1 Make empty-server restarts configurable in the mission menu
Add persistent last-disconnect and idle-interval controls with enabled/five-hour defaults. Apply saved settings immediately, reset idle time while occupied, and cover migration, routing, layout and restart behavior.
2026-10-14 14:05:46 +02:00

145 lines
7.7 KiB
Python

"""Execute the real empty-server gate and tick wrapper with native boundaries stubbed."""
from policy_harness import ROOT, body, run
MODE = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c'
SPAWN = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c'
def cpp(source):
source = source.replace('protected ', '').replace('PlayerManager manager', 'PlayerManager* manager')
for name in ('Replication', 'System', 'SCR_GameModeEndData', 'EGameOverTypes'):
source = source.replace(name + '.', name + '::')
source = source.replace('string.Format', 'EnforceString::Format')
for name in ('GetGame()', 'manager', 'm_Performance', 'm_Settings'):
source = source.replace(name + '.', name + '->')
return source
methods = '\n'.join(cpp(body(MODE, signature)) for signature in (
'void ObservePlayerDisconnect(', 'protected bool CheckEmptyServerRestart(',
'protected void ServerTick('))
run(r'''
#include <cassert>
#include <string>
namespace Replication {bool server=true;bool IsServer(){return server;}}
namespace System {int now=0;int GetTickCount(){return now;}}
namespace EGameOverTypes {enum {LOOSE=1};}
struct SCR_GameModeEndData {static int Create(int reason){return reason;}};
struct PlayerManager {int connected=0;int GetPlayerCount(){return connected;}};
struct Game {PlayerManager* manager=nullptr;PlayerManager* GetPlayerManager(){return manager;}} game;
Game* GetGame(){return &game;}
template<class... T> void PrintFormat(const char*,T...){}
struct Performance {int starts=0,ends=0;int BeginTick(){return ++starts;}void EndTick(int){ends++;}};
struct EnforceString {
static std::string Format(std::string pattern,int value){pattern.replace(pattern.find("%1"),2,std::to_string(value));return pattern;}
};
struct Settings {
int restart=1,hours=5;
int Get(std::string key){if(key=="restartOnEmpty")return restart;assert(key=="emptyRestartHours");return hours;}
};
struct Mode {
Settings settings;Settings* m_Settings=&settings;
bool m_Stopped=false,m_NativeEndHandled=false,m_HadConnectedPlayers=false,m_EmptyRestartRequested=false;
int m_EmptySince=System::now,phase=1,endCalls=0,measuredTicks=0;
std::string m_EndReason;
Performance performance;Performance* m_Performance=&performance;
bool Active(){return !m_Stopped&&!m_NativeEndHandled&&phase>0&&phase<5;}
// Leave native completion pending to prove repeated callbacks cannot end twice.
void EndGameMode(int reason){assert(reason==EGameOverTypes::LOOSE);endCalls++;}
void ServerTickMeasured(){measuredTicks++;}
''' + methods + r'''
};
struct Fixture {
PlayerManager manager;Mode mode;
Fixture(){Replication::server=true;game.manager=&manager;}
void tick(int now,int count){System::now=now;manager.connected=count;mode.ServerTick();}
};
int main(){
const int fiveHours=5*60*60*1000;
// Each newly loaded, continuously empty mission gets its own five-hour window.
System::now=123456;
for(int round=0;round<3;round++){
Fixture f;int start=System::now;
f.tick(start,0);f.tick(start+60*60*1000,0);f.tick(start+fiveHours-1,0);
assert(f.mode.endCalls==0);
int before=f.mode.measuredTicks;
f.tick(start+fiveHours,0);
assert(f.mode.endCalls==1&&f.mode.measuredTicks==before);
assert(f.mode.m_EndReason=="The server has been empty for 5 hours. Restarting the mission.");
f.tick(start+fiveHours+1000,0);assert(f.mode.endCalls==1);
}
// All native connections block a scheduled restart, even without a spawned life.
System::now=0;
{Fixture f;f.tick(fiveHours-1,0);f.tick(fiveHours,1);f.tick(2*fiveHours,1);
assert(f.mode.endCalls==0&&f.mode.measuredTicks==3);
f.tick(2*fiveHours+1000,0);
assert(f.mode.endCalls==1&&f.mode.m_EndReason=="All players disconnected. Restarting the mission.");}
// One of several players leaves; the last disconnect ends on the next tick.
System::now=0;
{Fixture f;f.tick(0,2);f.mode.ObservePlayerDisconnect();f.tick(1000,1);
assert(f.mode.endCalls==0);f.mode.ObservePlayerDisconnect();f.tick(2000,0);
assert(f.mode.endCalls==1);}
// Join/disconnect entirely between ticks still triggers a restart.
{Fixture f;f.mode.ObservePlayerDisconnect();assert(f.mode.endCalls==0);
f.tick(3000,0);assert(f.mode.endCalls==1);}
// A connection arriving before the deferred check prevents the restart.
{Fixture f;f.mode.ObservePlayerDisconnect();f.tick(4000,1);assert(f.mode.endCalls==0);
f.tick(5000,0);assert(f.mode.endCalls==1);}
// Preparation is covered before Active() allows normal tactical/service ticks.
for(bool disconnected:{false,true}){
System::now=0;Fixture f;f.mode.phase=0;
if(disconnected)f.mode.ObservePlayerDisconnect();
f.tick(disconnected?1000:fiveHours,0);
assert(f.mode.endCalls==1&&f.mode.measuredTicks==0&&f.mode.performance.starts==0);
}
// Independent controls: no idle restart at any age when both are disabled.
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.mode.settings.hours=0;
f.tick(0,1);f.mode.ObservePlayerDisconnect();f.tick(1000,0);f.tick(169*3600000,0);
assert(f.mode.endCalls==0);}
// Last-disconnect restart remains available when the periodic interval is off.
System::now=0;
{Fixture f;f.mode.settings.hours=0;f.tick(1000,1);f.tick(2000,0);assert(f.mode.endCalls==1);}
// With immediate restart disabled, the full configured interval starts at
// the last disconnect even if the mission has been occupied for many hours.
for(int hours:{1,5,168}){
System::now=0;Fixture f;f.mode.settings.restart=0;f.mode.settings.hours=hours;
f.tick(20*3600000,1);System::now+=100;f.mode.ObservePlayerDisconnect();int since=System::now;
f.tick(since,0);f.tick(since+hours*3600000-1,0);assert(f.mode.endCalls==0);
f.tick(since+hours*3600000,0);assert(f.mode.endCalls==1);
}
// A new connection resets the idle interval, including a short unaudited visit.
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.mode.settings.hours=1;
f.tick(3599999,1);System::now=3600000;f.mode.ObservePlayerDisconnect();
f.tick(7199999,0);assert(f.mode.endCalls==0);f.tick(7200000,0);assert(f.mode.endCalls==1);}
// Saved changes take effect on subsequent checks; disabling/enabling the
// interval does not forget elapsed empty time, and never restarts an occupied game.
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.tick(2*3600000,0);assert(f.mode.endCalls==0);
f.mode.settings.hours=0;f.tick(6*3600000,0);assert(f.mode.endCalls==0);
f.mode.settings.hours=1;f.tick(6*3600000+1,1);assert(f.mode.endCalls==0);
f.tick(6*3600000+2,0);assert(f.mode.endCalls==0);
f.mode.settings.restart=1;f.tick(6*3600000+3,0);assert(f.mode.endCalls==1);}
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.tick(2*3600000,0);f.mode.settings.hours=1;
f.tick(2*3600000+1,0);assert(f.mode.endCalls==1);}
// Client, stopped, already-ended and unavailable population/settings never reload.
for(int fault=0;fault<5;fault++){
System::now=0;Fixture f;
if(fault==0)Replication::server=false;
if(fault==1)f.mode.m_Stopped=true;
if(fault==2)f.mode.m_NativeEndHandled=true;
if(fault==3)game.manager=nullptr;
if(fault==4)f.mode.m_Settings=nullptr;
f.mode.ObservePlayerDisconnect();f.tick(fiveHours,0);assert(f.mode.endCalls==0);
}
}
''')
init = body(MODE, 'protected override void EOnInit(')
assert init.index('if (!Replication.IsServer()) return;') < init.index('m_EmptySince = System.GetTickCount();')
assert init.index('m_EmptySince = System.GetTickCount();') < init.index('CallLater(ServerTick, 1000, true)')
assert 'mode.ObservePlayerDisconnect();' in body(SPAWN, 'override void OnPlayerDisconnected_S(')
assert 'Remove(ServerTick)' in body(MODE, 'protected void StopServices(')
print('PASS: configurable independent restarts, idle clock reset and live setting changes; last disconnect, pending admission/spectator population, five-hour boundary, repeated empty rounds, preparation, authority and duplicate-end guards')