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
@@ -73,6 +73,9 @@ class ERR_GameMode : SCR_BaseGameMode
[RplProp()] [RplProp()]
protected string m_RecoveryStatus; protected string m_RecoveryStatus;
protected bool m_StartedPlayers; protected bool m_StartedPlayers;
protected bool m_HadConnectedPlayers;
protected bool m_EmptyRestartRequested;
protected int m_EmptyMissionStartedAt;
protected ref map<int, PlayerController> m_ExhaustedInitialAdmissions = new map<int, PlayerController>(); protected ref map<int, PlayerController> m_ExhaustedInitialAdmissions = new map<int, PlayerController>();
protected bool m_Stopped; protected bool m_Stopped;
protected bool m_NativeEndHandled; protected bool m_NativeEndHandled;
@@ -449,6 +452,38 @@ class ERR_GameMode : SCR_BaseGameMode
protected string SettingsPath() { return ERR_Settings.PATH; } 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) protected override void EOnInit(IEntity owner)
{ {
super.EOnInit(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(BindLocalObserver, 250, true);
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(ApplyLocalViewDistance, 1000, true); if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(ApplyLocalViewDistance, 1000, true);
if (!Replication.IsServer()) return; if (!Replication.IsServer()) return;
m_EmptyMissionStartedAt = System.GetTickCount();
m_Settings = new ERR_Settings(SettingsPath()); m_Settings = new ERR_Settings(SettingsPath());
if (!System.IsCLIParam("errNoPerf")) m_Performance = new ERR_Performance(); if (!System.IsCLIParam("errNoPerf")) m_Performance = new ERR_Performance();
PublishViewDistance(); PublishViewDistance();
@@ -564,6 +600,7 @@ class ERR_GameMode : SCR_BaseGameMode
protected void ServerTick() protected void ServerTick()
{ {
if (CheckEmptyServerRestart()) return;
if (!Replication.IsServer() || !Active()) return; if (!Replication.IsServer() || !Active()) return;
int started; int started;
if (m_Performance) started = m_Performance.BeginTick(); if (m_Performance) started = m_Performance.BeginTick();
@@ -618,9 +655,8 @@ class ERR_GameMode : SCR_BaseGameMode
} }
} }
else m_NoSurvivorsSince = -1; else m_NoSurvivorsSince = -1;
// A played round must finish after its last reserved body expires, even // Empty-server restart is checked by ServerTick before tactical work.
// with nobody connected. Keep tactical/observer work idle until players // Keep tactical/observer work idle when nobody is connected.
// return; a fresh unplayed round still waits without a restart loop.
if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return; if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
if (m_Performance) perfStamp = System.GetTickCount(); if (m_Performance) perfStamp = System.GetTickCount();
array<int> connected = {}; array<int> connected = {};
@@ -347,6 +347,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
ERR_GameMode mode = ERR_GameMode.Get(); ERR_GameMode mode = ERR_GameMode.Get();
if (mode) if (mode)
{ {
mode.ObservePlayerDisconnect();
mode.InitialAdmissionStarted(playerId); mode.InitialAdmissionStarted(playerId);
mode.Players().Disconnect(playerId); mode.Players().Disconnect(playerId);
} }
+17 -19
View File
@@ -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). 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 When the last player disconnects, the next server tick ends the mission through
end condition even when no clients are connected. Previously the empty-server the normal native end/reload sequence. This includes players who leave before
return reset that timer every tick. Once the last reconnect reservation expired admission finishes. Living reconnect reservations do not keep a completely empty
and its body was removed, the old round could remain open indefinitely. Returning mission running. While another player remains connected, the existing reserved
players still had a spent life, saw the spectator camera, and had to wait for the body and reconnect behavior still applies.
30-second defeat grace and the next round's generation.
The existing grace now advances while the server is empty. Living reserved bodies A fresh mission with no connections restarts after five hours of real elapsed
still prevent defeat, including the existing 15-minute reconnect window. A fresh time. Each newly loaded mission starts a new five-hour window. Mission time and
round with no admitted players waits normally and does not repeatedly restart. time-multiplier settings do not change this interval. Connected players, including
No life is refunded, no new body is substituted during a round, and connected spectators and players loading in, prevent the empty-server restart. The check
players retain the existing defeat and admission behavior. Reservation expiry, also runs during preparation; it stops with the mission's services. Both restart
start of the no-survivor grace, and exhausted-round termination now produce reasons are written to the server log.
explicit log messages.
`test_initial_admission_exhaustion.py` reproduces the old failure using the actual `test_empty_server_restart.py` executes the production restart check and tick
`ServerTick` body and covers reservation retention, expiry, grace cancellation, wrapper, covering the five-hour boundary, repeated empty rounds, last disconnect,
and a fresh idle round. Reconnect, admission and native-end policy checks also connections between ticks, preparation, authority and duplicate-end prevention.
pass. The observed live server generated its next round and successfully spawned The restart uses the existing native end/reload path. These offline checks do
the returning player; that recovery preceded deployment of this source fix. not establish live dedicated-server disconnect/reload timing.
+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==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();} 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());} 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; {Fixture f;f.m.m_StartedPlayers=true;f.m.roster.lives[1].UsedLife=true;
game.players.connected.clear();f.m.roster.survivor=true; game.players.connected.clear();f.m.roster.survivor=true;
f.tick(900000);assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1); 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.m.roster.survivor=false;f.tick(901000);assert(f.m.m_NoSurvivorsSince==901000);
f.tick(930999);assert(f.m.Active());f.tick(931000); f.tick(930999);assert(f.m.Active());f.tick(931000);
assert(!f.m.Active()&&f.m.m_EndReason=="No surviving prisoners remain.");} 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); {Fixture f;game.players.connected.clear();f.tick(0);f.tick(36000000);
assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1);} assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1);}
// A valid reservation/recovered survivor cancels a pending empty-server loss. // A valid reservation/recovered survivor cancels a pending empty-server loss.
+1
View File
@@ -8,6 +8,7 @@ from prefab_identity import check_prefabs
ROOT=Path(__file__).resolve().parents[1] ROOT=Path(__file__).resolve().parents[1]
BASE='58D0FB3206B6F859' BASE='58D0FB3206B6F859'
def check(): 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_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_landing.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_descent.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_descent.py')],check=True)