Restart empty missions after the last disconnect and every five hours

Use connected player counts to protect loading players and spectators, and elapsed real time for idle restarts. Reuse native mission termination and cover restart timing, authority and lifecycle guards with targeted regressions.
This commit is contained in:
2026-10-14 13:56:56 +02:00
parent bf85f62dca
commit 0048527fe2
6 changed files with 164 additions and 24 deletions
+102
View File
@@ -0,0 +1,102 @@
"""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 + '::')
for name in ('GetGame()', 'manager', 'm_Performance'):
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 Mode {
bool m_Stopped=false,m_NativeEndHandled=false,m_HadConnectedPlayers=false,m_EmptyRestartRequested=false;
int m_EmptyMissionStartedAt=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 five 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);
}
// Client, stopped, already-ended and unavailable population must never reload.
for(int fault=0;fault<4;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;
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_EmptyMissionStartedAt = System.GetTickCount();')
assert init.index('m_EmptyMissionStartedAt = 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: last disconnect, pending admission/spectator population, five-hour boundary, repeated empty rounds, preparation, authority and duplicate-end guards')
+4 -2
View File
@@ -75,14 +75,16 @@ int main(){
if(fault==0){server=false;}if(fault==1){f.m.m_Phase=0;}if(fault==2){f.m.m_Phase=5;}
if(fault==3){f.m.m_StartedPlayers=true;}if(fault==4){game.players.connected[1]=&f.second;}if(fault==5){f.m.roster.lives.clear();}
f.exhaust();assert(f.m.m_ExhaustedInitialAdmissions.values.empty());}
// Empty server must retire a played round after the last reserved body expires.
// The inner survivor check retains its expiry behavior; the outer tick now
// handles empty-server restarts first (see test_empty_server_restart.py).
{Fixture f;f.m.m_StartedPlayers=true;f.m.roster.lives[1].UsedLife=true;
game.players.connected.clear();f.m.roster.survivor=true;
f.tick(900000);assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1);
f.m.roster.survivor=false;f.tick(901000);assert(f.m.m_NoSurvivorsSince==901000);
f.tick(930999);assert(f.m.Active());f.tick(931000);
assert(!f.m.Active()&&f.m.m_EndReason=="No surviving prisoners remain.");}
// A new empty round waits indefinitely; it must not restart in a loop.
// Empty, unplayed rounds do not trigger the inner no-survivor check.
// Their five-hour deadline belongs to the outer tick, tested separately.
{Fixture f;game.players.connected.clear();f.tick(0);f.tick(36000000);
assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1);}
// A valid reservation/recovered survivor cancels a pending empty-server loss.