Make empty-server restarts configurable in the mission menu

Add persistent last-disconnect and idle-interval controls with enabled/five-hour defaults. Apply saved settings immediately, reset idle time while occupied, and cover migration, routing, layout and restart behavior.
This commit is contained in:
2026-10-14 14:05:46 +02:00
parent 0048527fe2
commit e4ca37dfc1
13 changed files with 242 additions and 61 deletions
+42 -3
View File
@@ -10,7 +10,7 @@ source = BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c'
layout = BASE / 'addons/core/UI/layouts/ERR/AdminMenu.layout'
keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array<string> Keys('))
settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array<string> Keys('))
assert len(keys) == len(set(keys)) and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "planeReconEnabled"}
assert len(keys) == len(set(keys)) and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "planeReconEnabled", "restartOnEmpty"}
assert set(keys) <= set(settings) # Every menu field must have a server setting.
# Native layouts place child widget declarations inside an anonymous Children
# block. A missing brace can preserve most widget names yet prevent menu load.
@@ -53,7 +53,7 @@ assert 'Apply_planeReconEnabled' not in names # Saved-state toggle pair, not a
assert len(names) == len(set(names)), 'Menu widget names must be unique'
for key in keys:
assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key, 'Help_' + key))
if key not in ('aiDoctrine','aiShadow','motorbikeCount','planeAltitudeMetres','planeReplacementSeconds','planeSectorSeconds','fieldCampCount','fieldHospitalCount','startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount','objectGuardEvery','objectGuardHQMode','startWeaponCount','startMagazineCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names
if key not in ('emptyRestartHours','aiDoctrine','aiShadow','motorbikeCount','planeAltitudeMetres','planeReplacementSeconds','planeSectorSeconds','fieldCampCount','fieldHospitalCount','startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount','objectGuardEvery','objectGuardHQMode','startWeaponCount','startMagazineCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names
else: assert 'Random_startMode' not in names
for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'JournalContent', 'JournalScroll', 'PauseJournal', 'LatestJournal', 'Status'):
assert name in names
@@ -100,7 +100,7 @@ int main() {
assert(Fields::FollowJournal(false,0,200,400,false));
assert(Fields::FollowJournal(true,0,0,400,false));
std::vector<std::pair<string,string>> cases={
{"aiDoctrine","mission doctrine"},{"aiShadow","mission aishadow"},{"automaticEquipmentChancePercent","hq equipment"},{"startMode","mission start"},{"radioDefenceSeconds","mission radio"},{"aiIntelChancePercent","intel carrying"},
{"emptyRestartHours","mission emptyhours"},{"aiDoctrine","mission doctrine"},{"aiShadow","mission aishadow"},{"automaticEquipmentChancePercent","hq equipment"},{"startMode","mission start"},{"radioDefenceSeconds","mission radio"},{"aiIntelChancePercent","intel carrying"},
{"intelRevealChancePercent","intel reveal"},{"startTimeMinutes","mission time"},
{"timeMultiplier","mission speed"},{"startDateCode","mission date"},
{"weatherProfile","mission weather"},{"viewDistanceMetres","mission viewdistance"},{"reportIntervalSeconds","hq interval"},
@@ -135,6 +135,45 @@ assert 'm_Controller.ERR_AdminSubmit("mission keepweapon set " + retain, true)'
assert click.index('!m_Ready') < click.index('"mission keepweapon set "')
print('PASS: explicit weapon retention buttons use the same ready/authorized server command path')
assert all(name in names for name in ('RestartEmptyOn', 'RestartEmptyOff', 'Saved_restartOnEmpty'))
assert click.index('!m_Ready') < click.index('"mission restartempty set "')
toggle = body(source, 'if (name == "RestartEmptyOn" || name == "RestartEmptyOff")')
toggle = toggle.replace('m_Controller.', 'm_Controller->')
run(r'''
#include <cassert>
#include <string>
using string=std::string;
struct Controller {int calls=0;string last;void ERR_AdminSubmit(string command,bool menu){assert(menu);calls++;last=command;}};
struct Menu {
int m_RestartEmptyState=-1;float m_ClickDelay=0;Controller controller;Controller* m_Controller=&controller;
void SetText(string,string){}
void Click(string name){
''' + toggle + r'''
}
};
int main(){
Menu m;m.Click("RestartEmptyOn");assert(m.controller.calls==0);
m.m_RestartEmptyState=0;m.Click("RestartEmptyOn");
assert(m.controller.last=="mission restartempty set 1"&&m.controller.calls==1);
m.m_RestartEmptyState=1;m.Click("RestartEmptyOff");
assert(m.controller.last=="mission restartempty set 0"&&m.controller.calls==2);
}
''')
# All four rows remain inside the visible Mission page with no overlapping controls.
page = body(layout, 'FrameWidgetClass { Name "SettingsPage"')
rectangles = []
for line in page.splitlines()[1:]:
if not line.startswith(('TextWidgetClass', 'ButtonWidgetClass')):
continue
name = re.search(r'Name "([^"]+)"', line)[1]
x, y, width, height = (int(re.search(r'\b' + prop + r' (\d+)', line)[1])
for prop in ('PositionX', 'PositionY', 'SizeX', 'SizeY'))
assert x + width <= 1000 and y + height <= 486, name
for other, ox, oy, ow, oh in rectangles:
assert x + width <= ox or ox + ow <= x or y + height <= oy or oy + oh <= y, (name, other)
rectangles.append((name, x, y, width, height))
print('PASS: restart toggle submits authorized settings commands; Mission controls fit without overlap')
# The edit page must carry every control bound by the extension, with a valid row.
items_menu = (ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ServerItemsMenu.c').read_text()
for name in ('ItemsMod','ItemsShow','ItemsEnableAll','ItemsDisableAll','ItemsPrevious','ItemsNext','ItemsPresets','ItemsPresetLoad','ItemsPresetDelete','ItemsPresetName','ItemsPresetSave','ItemsStorage','ItemsContent'):
+6
View File
@@ -13,6 +13,12 @@ run('#include <cassert>\n#include <string>\n'+rules+r'''
int main(){
assert(CoreSetting("mission","doctrine")=="aiDoctrine");
assert(CoreSetting("mission","aishadow")=="aiShadow");
assert(CoreSetting("mission","restartempty")=="restartOnEmpty");
assert(CoreSetting("mission","emptyhours")=="emptyRestartHours");
assert(Valid("restartOnEmpty",0)&&Valid("restartOnEmpty",1));
assert(!Valid("restartOnEmpty",-1)&&!Valid("restartOnEmpty",2));
assert(Valid("emptyRestartHours",0)&&Valid("emptyRestartHours",1)&&Valid("emptyRestartHours",5)&&Valid("emptyRestartHours",168));
assert(!Valid("emptyRestartHours",-1)&&!Valid("emptyRestartHours",169));
for(std::string key:{"aiDoctrine","aiShadow"}){ assert(Valid(key,0)&&Valid(key,1)); assert(!Valid(key,-1)&&!Valid(key,2)); }
assert(CoreSetting("mission","motorbikes")=="motorbikeCount");
assert(Valid("motorbikeCount",0)&&Valid("motorbikeCount",2)&&Valid("motorbikeCount",6));
+50 -8
View File
@@ -8,7 +8,8 @@ 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('string.Format', 'EnforceString::Format')
for name in ('GetGame()', 'manager', 'm_Performance', 'm_Settings'):
source = source.replace(name + '.', name + '->')
return source
@@ -28,9 +29,17 @@ struct Game {PlayerManager* manager=nullptr;PlayerManager* GetPlayerManager(){re
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 EnforceString {
static std::string Format(std::string pattern,int value){pattern.replace(pattern.find("%1"),2,std::to_string(value));return pattern;}
};
struct Settings {
int restart=1,hours=5;
int Get(std::string key){if(key=="restartOnEmpty")return restart;assert(key=="emptyRestartHours");return hours;}
};
struct Mode {
Settings settings;Settings* m_Settings=&settings;
bool m_Stopped=false,m_NativeEndHandled=false,m_HadConnectedPlayers=false,m_EmptyRestartRequested=false;
int m_EmptyMissionStartedAt=System::now,phase=1,endCalls=0,measuredTicks=0;
int m_EmptySince=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;}
@@ -55,7 +64,7 @@ int main(){
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.");
assert(f.mode.m_EndReason=="The server has been empty for 5 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.
@@ -82,21 +91,54 @@ int main(){
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++){
// Independent controls: no idle restart at any age when both are disabled.
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.mode.settings.hours=0;
f.tick(0,1);f.mode.ObservePlayerDisconnect();f.tick(1000,0);f.tick(169*3600000,0);
assert(f.mode.endCalls==0);}
// Last-disconnect restart remains available when the periodic interval is off.
System::now=0;
{Fixture f;f.mode.settings.hours=0;f.tick(1000,1);f.tick(2000,0);assert(f.mode.endCalls==1);}
// With immediate restart disabled, the full configured interval starts at
// the last disconnect even if the mission has been occupied for many hours.
for(int hours:{1,5,168}){
System::now=0;Fixture f;f.mode.settings.restart=0;f.mode.settings.hours=hours;
f.tick(20*3600000,1);System::now+=100;f.mode.ObservePlayerDisconnect();int since=System::now;
f.tick(since,0);f.tick(since+hours*3600000-1,0);assert(f.mode.endCalls==0);
f.tick(since+hours*3600000,0);assert(f.mode.endCalls==1);
}
// A new connection resets the idle interval, including a short unaudited visit.
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.mode.settings.hours=1;
f.tick(3599999,1);System::now=3600000;f.mode.ObservePlayerDisconnect();
f.tick(7199999,0);assert(f.mode.endCalls==0);f.tick(7200000,0);assert(f.mode.endCalls==1);}
// Saved changes take effect on subsequent checks; disabling/enabling the
// interval does not forget elapsed empty time, and never restarts an occupied game.
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.tick(2*3600000,0);assert(f.mode.endCalls==0);
f.mode.settings.hours=0;f.tick(6*3600000,0);assert(f.mode.endCalls==0);
f.mode.settings.hours=1;f.tick(6*3600000+1,1);assert(f.mode.endCalls==0);
f.tick(6*3600000+2,0);assert(f.mode.endCalls==0);
f.mode.settings.restart=1;f.tick(6*3600000+3,0);assert(f.mode.endCalls==1);}
System::now=0;
{Fixture f;f.mode.settings.restart=0;f.tick(2*3600000,0);f.mode.settings.hours=1;
f.tick(2*3600000+1,0);assert(f.mode.endCalls==1);}
// Client, stopped, already-ended and unavailable population/settings never reload.
for(int fault=0;fault<5;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;
if(fault==4)f.mode.m_Settings=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 init.index('if (!Replication.IsServer()) return;') < init.index('m_EmptySince = System.GetTickCount();')
assert init.index('m_EmptySince = 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')
print('PASS: configurable independent restarts, idle clock reset and live setting changes; last disconnect, pending admission/spectator population, five-hour boundary, repeated empty rounds, preparation, authority and duplicate-end guards')
+1 -1
View File
@@ -5,7 +5,7 @@ from policy_harness import ROOT,body,run
spec=importlib.util.spec_from_file_location('import_settings',BASE/'tools/import_settings.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
result,deferred=m.merge({'configVersion':1,'reportIntervalSeconds':20.0,'supportSquadLimit':2,'responseRadiusMetres':1500,'convoyCount':3},{'aiIntelChancePercent':0,'intelRevealChancePercent':100})
assert result==dict(aiDoctrine=0,aiShadow=0,planeAltitudeMetres=250,planeReplacementSeconds=60,planeSectorSeconds=120,planeReconEnabled=1,roadblockCount=4,civilianTrafficCount=3,startMode=0,keepWeaponOnUnconscious=1,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,viewDistanceMetres=0,automaticEquipmentChancePercent=25,antiAirChancePercent=7,antiPersonnelMineChancePercent=60,automaticConvoyEnabled=1,convoyCount=3,heliTimerMinutes=15)
assert result==dict(aiDoctrine=0,aiShadow=0,planeAltitudeMetres=250,planeReplacementSeconds=60,planeSectorSeconds=120,planeReconEnabled=1,roadblockCount=4,civilianTrafficCount=3,startMode=0,keepWeaponOnUnconscious=1,restartOnEmpty=1,emptyRestartHours=5,reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,viewDistanceMetres=0,automaticEquipmentChancePercent=25,antiAirChancePercent=7,antiPersonnelMineChancePercent=60,automaticConvoyEnabled=1,convoyCount=3,heliTimerMinutes=15)
assert deferred==[]
for count in (0,1,3,20):
values,notes=m.merge(ai={'convoyCount':count})
+21
View File
@@ -55,6 +55,27 @@ int main(){
assert(!ERR_Settings::Randomizable("startMode")&&!ERR_Settings::ValidSelection("startMode",-1)&&!ERR_Settings::ValidSelection("startMode",-2));
disk["old"]={{"reportIntervalSeconds",10},{"supportSquadLimit",4},{"responseRadiusMetres",2000},{"aiIntelChancePercent",60},{"intelRevealChancePercent",15},{"radioDefenceSeconds",180}};
ERR_Settings settings;assert(settings.Read("old",settings.m_Values)&&settings.Get("startMode")==0);
// Older configurations gain the enabled/five-hour defaults. Each restart
// setting survives a save/reload, while invalid selections cannot change it.
for(string key:{"restartOnEmpty","emptyRestartHours"}){
int expected=key=="restartOnEmpty"?1:5;
assert(ERR_Settings::AddedDefault(key)==expected&&settings.Get(key)==expected);
assert(!ERR_Settings::Randomizable(key));
for(float bad:{-2.f,-1.f,.5f,169.f}){
disk["bad"]=disk["old"];disk["bad"][key]=bad;Values ignored;
assert(!settings.Read("bad",ignored));
}
int choice=key=="restartOnEmpty"?0:168;
assert(settings.Set(key,choice));
ERR_Settings restored;assert(restored.Read("settings",restored.m_Values));
assert(restored.Get(key)==choice);
failSave=true;assert(!settings.Set(key,expected));failSave=false;
assert(settings.Get(key)==choice);
assert(settings.Set(key,expected));
}
int restartSelection=-1;
assert(ERR_CommandArguments::Selection("restartOnEmpty","on",restartSelection)&&restartSelection==1);
assert(ERR_CommandArguments::Selection("restartOnEmpty","off",restartSelection)&&restartSelection==0);
for(float bad:{-1.f,-2.f,2.f,.5f}){disk["bad"]=disk["old"];disk["bad"]["startMode"]=bad;Values ignored;assert(!settings.Read("bad",ignored));}
// Newly introduced doctrine choices migrate safely and survive the same transaction.