diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c index 29e9a81..3ea3f85 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c @@ -73,6 +73,9 @@ class ERR_GameMode : SCR_BaseGameMode [RplProp()] protected string m_RecoveryStatus; protected bool m_StartedPlayers; + protected bool m_HadConnectedPlayers; + protected bool m_EmptyRestartRequested; + protected int m_EmptyMissionStartedAt; protected ref map m_ExhaustedInitialAdmissions = new map(); protected bool m_Stopped; protected bool m_NativeEndHandled; @@ -449,6 +452,38 @@ class ERR_GameMode : SCR_BaseGameMode protected string SettingsPath() { return ERR_Settings.PATH; } + // A connection can arrive and leave between server ticks, even before audit + // or spawning. Defer the population check until native disconnect cleanup ends. + void ObservePlayerDisconnect() + { + if (!Replication.IsServer() || m_Stopped || m_NativeEndHandled) return; + m_HadConnectedPlayers = true; + } + + protected bool CheckEmptyServerRestart() + { + if (!Replication.IsServer() || m_Stopped || m_NativeEndHandled) return false; + if (m_EmptyRestartRequested) return true; + PlayerManager manager = GetGame().GetPlayerManager(); + if (!manager) return false; + // Count all connections, including admission and spectators, not living bodies. + if (manager.GetPlayerCount() > 0) + { + m_HadConnectedPlayers = true; + return false; + } + // Real elapsed milliseconds: mission clock settings cannot accelerate this. + if (!m_HadConnectedPlayers && System.GetTickCount() - m_EmptyMissionStartedAt < 18000000) return false; + m_EmptyRestartRequested = true; + m_EndReason = "The server has been empty for five hours. Restarting the mission."; + if (m_HadConnectedPlayers) m_EndReason = "All players disconnected. Restarting the mission."; + PrintFormat("Reintroduced [round lifecycle]: %1", m_EndReason); + // Native termination also handles PREPARING, before Transition is available. + // OnGameModeEnd stops our services; vanilla owns the scenario reload. + EndGameMode(SCR_GameModeEndData.Create(EGameOverTypes.LOOSE)); + return true; + } + protected override void EOnInit(IEntity owner) { super.EOnInit(owner); @@ -457,6 +492,7 @@ class ERR_GameMode : SCR_BaseGameMode if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true); if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(ApplyLocalViewDistance, 1000, true); if (!Replication.IsServer()) return; + m_EmptyMissionStartedAt = System.GetTickCount(); m_Settings = new ERR_Settings(SettingsPath()); if (!System.IsCLIParam("errNoPerf")) m_Performance = new ERR_Performance(); PublishViewDistance(); @@ -564,6 +600,7 @@ class ERR_GameMode : SCR_BaseGameMode protected void ServerTick() { + if (CheckEmptyServerRestart()) return; if (!Replication.IsServer() || !Active()) return; int started; if (m_Performance) started = m_Performance.BeginTick(); @@ -618,9 +655,8 @@ class ERR_GameMode : SCR_BaseGameMode } } else m_NoSurvivorsSince = -1; - // A played round must finish after its last reserved body expires, even - // with nobody connected. Keep tactical/observer work idle until players - // return; a fresh unplayed round still waits without a restart loop. + // Empty-server restart is checked by ServerTick before tactical work. + // Keep tactical/observer work idle when nobody is connected. if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return; if (m_Performance) perfStamp = System.GetTickCount(); array connected = {}; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c index 016b5ff..0071432 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c @@ -347,6 +347,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic ERR_GameMode mode = ERR_GameMode.Get(); if (mode) { + mode.ObservePlayerDisconnect(); mode.InitialAdmissionStarted(playerId); mode.Players().Disconnect(playerId); } diff --git a/docs/player-lifecycle.md b/docs/player-lifecycle.md index 3a0af0a..87974a0 100644 --- a/docs/player-lifecycle.md +++ b/docs/player-lifecycle.md @@ -68,25 +68,23 @@ The combined unconscious-camera/weapon revision passed eighteen native Workbench The server's scene deadline now reaches the existing owned-ticket rejection even while a remote native preload completion is delayed. Rejection cannot assign a character, consume a life, or revive a stale ticket after a late acknowledgement. The ordinary verified admission path remains intact. Actual-method red/green regressions pass; the normal generated route independently verifies ordinary native admission. Remote timeout timing remains a multiplayer test requirement. [Evidence and scope](mission-hq-fixes-2026-10-01.md). -## Exhausted rounds on an empty server (5 October 2026) +## Empty-server restarts (14 October 2026) -An active round that has already admitted players now evaluates its no-survivor -end condition even when no clients are connected. Previously the empty-server -return reset that timer every tick. Once the last reconnect reservation expired -and its body was removed, the old round could remain open indefinitely. Returning -players still had a spent life, saw the spectator camera, and had to wait for the -30-second defeat grace and the next round's generation. +When the last player disconnects, the next server tick ends the mission through +the normal native end/reload sequence. This includes players who leave before +admission finishes. Living reconnect reservations do not keep a completely empty +mission running. While another player remains connected, the existing reserved +body and reconnect behavior still applies. -The existing grace now advances while the server is empty. Living reserved bodies -still prevent defeat, including the existing 15-minute reconnect window. A fresh -round with no admitted players waits normally and does not repeatedly restart. -No life is refunded, no new body is substituted during a round, and connected -players retain the existing defeat and admission behavior. Reservation expiry, -start of the no-survivor grace, and exhausted-round termination now produce -explicit log messages. +A fresh mission with no connections restarts after five hours of real elapsed +time. Each newly loaded mission starts a new five-hour window. Mission time and +time-multiplier settings do not change this interval. Connected players, including +spectators and players loading in, prevent the empty-server restart. The check +also runs during preparation; it stops with the mission's services. Both restart +reasons are written to the server log. -`test_initial_admission_exhaustion.py` reproduces the old failure using the actual -`ServerTick` body and covers reservation retention, expiry, grace cancellation, -and a fresh idle round. Reconnect, admission and native-end policy checks also -pass. The observed live server generated its next round and successfully spawned -the returning player; that recovery preceded deployment of this source fix. +`test_empty_server_restart.py` executes the production restart check and tick +wrapper, covering the five-hour boundary, repeated empty rounds, last disconnect, +connections between ticks, preparation, authority and duplicate-end prevention. +The restart uses the existing native end/reload path. These offline checks do +not establish live dedicated-server disconnect/reload timing. diff --git a/tests/test_empty_server_restart.py b/tests/test_empty_server_restart.py new file mode 100644 index 0000000..808212f --- /dev/null +++ b/tests/test_empty_server_restart.py @@ -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 +#include +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 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') diff --git a/tests/test_initial_admission_exhaustion.py b/tests/test_initial_admission_exhaustion.py index c0041f3..17167e2 100644 --- a/tests/test_initial_admission_exhaustion.py +++ b/tests/test_initial_admission_exhaustion.py @@ -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. diff --git a/tools/check.py b/tools/check.py index c8f131f..3eed607 100644 --- a/tools/check.py +++ b/tools/check.py @@ -8,6 +8,7 @@ from prefab_identity import check_prefabs ROOT=Path(__file__).resolve().parents[1] BASE='58D0FB3206B6F859' def check(): + subprocess.run([sys.executable,str(ROOT/'tests/test_empty_server_restart.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_factions.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_landing.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_descent.py')],check=True)