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:
@@ -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'):
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user