Files
EscapistsReintroduced/tests/test_initial_admission_exhaustion.py
T
jakub 0048527fe2 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.
2026-10-14 13:56:56 +02:00

108 lines
8.7 KiB
Python

"""Actual admission-exhaustion/tick rules; engine population and services substituted."""
import re
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(s):
# Test mission scheduling inside the existing performance wrapper.
s=s.replace('ServerTickMeasured(', 'ServerTick(').replace('System.', 'System::').replace('ERR_PerfMetric.', 'ERR_PerfMetric::').replace('int perfStamp;', 'int perfStamp=0;')
s=s.replace('ERR_HQ.Event','ERR_HQ::Event').replace('protected ', '').replace('Replication.IsServer()', 'server').replace('Replication.BumpMe()', '(void)0')
s=s.replace('array<int> connected = {};','std::vector<int> connected;').replace('connected.IsEmpty()', 'connected.empty()')
s=s.replace('foreach (int id : connected)', 'for (int id : connected)')
s=s.replace('ERR_PlayerLife life', 'Life* life').replace('SCR_PlayerController controller', 'Controller* controller').replace('PlayerController controller', 'Controller* controller')
s=s.replace('SCR_PlayerController.Cast(', 'Controller::Cast(').replace('ERR_Phase.DEFEAT','5').replace('ERR_Phase.RADIO_SEARCH','2')
for v in ('m_Performance','m_Roster','controller','life','m_Command','m_CivilianTraffic','m_Extraction','m_RadioDefence','m_Recovery','GetGame()','GetWorld()','GetPlayerManager()'):
s=s.replace(v+'.', v+'->')
return s
methods='\n'.join(cpp(body(MODE,x)) for x in ('void InitialAdmissionStarted(', 'void InitialAdmissionExhausted(', 'protected bool InitialAdmissionsExhausted(', 'protected void ServerTickMeasured('))
run(r'''
#include <cassert>
#include <map>
#include <vector>
#include <string>
bool server=true;
template<class... T>void PrintFormat(const char*,T...){}
struct Controller {void* actor=nullptr;void* GetControlledEntity(){return actor;}static Controller* Cast(Controller* c){return c;}void ERR_UpdateObserverServer(){}};
struct Life {bool Connected=true,UsedLife=false;void* Actor=nullptr;};
struct Roster {std::map<int,Life> lives;bool survivor=false;Life* Get(int id){auto i=lives.find(id);return i==lives.end()?nullptr:&i->second;}bool HasSurvivor(){return survivor;}};
struct Players {std::map<int,Controller*> connected;void GetPlayers(std::vector<int>& v){for(auto p:connected)v.push_back(p.first);}int GetPlayerCount(){return connected.size();}Controller* GetPlayerController(int id){auto i=connected.find(id);return i==connected.end()?nullptr:i->second;}};
struct Game {Players players;Players* GetPlayerManager(){return &players;}} game;Game* GetGame(){return &game;}
struct World {float now=0;float GetWorldTime(){return now;}} world;
struct ERR_HQ {static void Event(std::string,std::string){}};
struct Service {bool defeat=false;bool DefeatDue(float){return defeat;}void Tick(float=0){}void Maintain(float){}void Invoke(float){}};
struct Exhausted {std::map<int,Controller*> values;void Remove(int id){values.erase(id);}void Set(int id,Controller* c){values[id]=c;}bool Contains(int id){return values.count(id);}Controller* Get(int id){return values.at(id);}};
namespace System {int GetTickCount(){return 0;}}
namespace ERR_PerfMetric {enum {MAINTENANCE,CIVILIANS,FORCES,OBSERVERS,HQ_TICK,LISTENERS};}
struct Performance {int Mark(int,int stamp){return stamp;}void Stop(int){}};
struct Mode {
Performance* m_Performance=nullptr;
void TickForces(float){}
bool m_StartedPlayers=false;int m_Phase=1;float m_NoSurvivorsSince=-1;std::string m_EndReason;
Exhausted m_ExhaustedInitialAdmissions;Roster roster;Roster* m_Roster=&roster;
Service service,m_OnTick;Service *m_Extraction=&service,*m_RadioDefence=&service,*m_Recovery=&service,*m_Command=&service,*m_CivilianTraffic=nullptr;
World* GetWorld(){return &world;}bool Active(){return m_Phase>0&&m_Phase<5;}void Transition(int p){m_Phase=p;}bool RefreshRadioFeedback(){return false;}
'''+methods+r'''
};
struct Fixture {Mode m;Controller first,second;Fixture(){server=true;world.now=0;game.players.connected={{1,&first}};m.roster.lives[1]=Life();}void tick(float t){world.now=t;m.ServerTick();}void exhaust(){m.InitialAdmissionExhausted(1,&first);}};
int main(){
{Fixture f;f.m.roster.survivor=true;f.m.service.defeat=true;f.tick(31000);
assert(!f.m.Active() && f.m.m_EndReason=="Everybody died. All prisoners are unconscious and nobody can recover or receive help.");}
// Sole exhausted first admission reaches existing grace, without a life/body.
{Fixture f;f.exhaust();f.tick(0);f.tick(29999);assert(f.m.Active());f.tick(30000);
assert(f.m.m_Phase==5&&!f.m.m_StartedPlayers&&!f.m.roster.lives[1].UsedLife);
assert(f.m.m_EndReason=="No prisoners could be admitted to this round.");}
// Not-yet-audited arrivals block the timer before they have any roster entry.
{Fixture f;f.exhaust();f.tick(0);game.players.connected[2]=&f.second;f.tick(29000);
assert(f.m.m_NoSurvivorsSince==-1);f.m.roster.lives[2]=Life();f.tick(60000);assert(f.m.Active());
f.m.InitialAdmissionExhausted(2,&f.second);f.tick(61000);f.tick(90999);assert(f.m.Active());f.tick(91000);assert(!f.m.Active());}
// A retry/pending new attempt clears exhaustion immediately, not one tick later.
{Fixture f;f.exhaust();f.tick(0);f.m.InitialAdmissionStarted(1);assert(f.m.m_NoSurvivorsSince==-1);
f.tick(90000);assert(f.m.Active()&&!f.m.InitialAdmissionsExhausted());
f.exhaust();f.tick(91000);f.tick(120999);assert(f.m.Active());f.tick(121000);assert(!f.m.Active());}
// Empty population and disconnect cleanup erase the initial failure grace.
{Fixture f;f.exhaust();f.tick(0);game.players.connected.clear();f.tick(30000);assert(f.m.m_NoSurvivorsSince==-1&&f.m.Active());
f.m.InitialAdmissionStarted(1);game.players.connected[1]=&f.first;f.tick(60000);assert(f.m.Active());}
// Recycled connection/controller, live body and invalid roster never inherit it.
for(int fault=0;fault<5;fault++){Fixture f;f.exhaust();f.tick(0);
if(fault==0)game.players.connected[1]=&f.second;
if(fault==1)f.first.actor=&f.first;
if(fault==2)f.m.roster.lives[1].UsedLife=true;
if(fault==3)f.m.roster.lives[1].Actor=&f.first;
if(fault==4)f.m.roster.lives[1].Connected=false;
f.tick(30000);assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1);}
// Invalid phase, authority, prior participation or controller cannot record failure.
for(int fault=0;fault<6;fault++){Fixture f;
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());}
// 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.");}
// 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.
{Fixture f;f.m.m_StartedPlayers=true;game.players.connected.clear();f.tick(0);
f.m.roster.survivor=true;f.tick(29999);f.tick(60000);
assert(f.m.Active()&&f.m.m_NoSurvivorsSince==-1);}
// Existing actual first spawn changes participation; normal death grace is unchanged.
{Fixture f;f.exhaust();f.m.InitialAdmissionStarted(1);f.m.m_StartedPlayers=true;f.m.roster.lives[1].UsedLife=true;f.m.roster.survivor=true;
f.tick(30000);assert(f.m.m_NoSurvivorsSince==-1&&f.m.Active());f.m.roster.survivor=false;f.tick(31000);f.tick(61000);
assert(f.m.m_Phase==5&&f.m.m_EndReason=="No surviving prisoners remain.");}
}
''')
# Tie the tested state seam to actual budget exhaustion and new/disconnected admission.
do_spawn=body(SPAWN,'protected override void DoSpawn_S(')
assert do_spawn.index('Observe(playerId, ERR_ObserverReason.SPAWN_UNAVAILABLE);\n mode.InitialAdmissionExhausted')>do_spawn.index('if (attempts >= 30)')
audit=body(SPAWN,'override void OnPlayerAuditSuccess_S(')
assert audit.index('mode.InitialAdmissionStarted(playerId);')<audit.index('m_Attempts.Set(playerId, 0);')
disconnect=body(SPAWN,'override void OnPlayerDisconnected_S(')
assert 'mode.InitialAdmissionStarted(playerId);' in disconnect
print('PASS: actual initial admission exhaustion and ServerTick grace; new/pending/empty/controller/life guards, normal death preserved')