From 0048527fe2fa33fa9f17348a5c2d1df324e875a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Wed, 14 Oct 2026 13:56:56 +0200 Subject: [PATCH] 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. --- .../Scripts/Game/ERR/00_Core/ERR_GameMode.c | 42 +++++++- .../Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c | 1 + docs/player-lifecycle.md | 36 +++---- tests/test_empty_server_restart.py | 102 ++++++++++++++++++ tests/test_initial_admission_exhaustion.py | 6 +- tools/check.py | 1 + 6 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 tests/test_empty_server_restart.py 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)