From e4ca37dfc1f95f1738559af830241396fda57d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Wed, 14 Oct 2026 14:05:46 +0200 Subject: [PATCH] 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. --- .../Scripts/Game/ERR/00_Core/ERR_AdminMenu.c | 25 ++++++- .../Game/ERR/00_Core/ERR_AdminService.c | 9 ++- .../Scripts/Game/ERR/00_Core/ERR_GameMode.c | 16 +++-- .../Scripts/Game/ERR/00_Core/ERR_Settings.c | 10 ++- addons/core/UI/layouts/ERR/AdminMenu.layout | 72 +++++++++++-------- docs/configuration.md | 19 +++++ docs/player-lifecycle.md | 18 +++-- tests/test_admin_menu.py | 45 +++++++++++- tests/test_admin_setting_policy.py | 6 ++ tests/test_empty_server_restart.py | 58 ++++++++++++--- tests/test_settings_migration.py | 2 +- tests/test_start_settings.py | 21 ++++++ tools/import_settings.py | 2 + 13 files changed, 242 insertions(+), 61 deletions(-) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c index a007dd2..4b39df9 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c @@ -8,7 +8,7 @@ class ERR_AdminMenuFields { static array Keys() { - return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"}; + return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"}; } static bool Available(string key, bool hq, bool intel) { @@ -64,6 +64,7 @@ class ERR_AdminMenuFields else if (key == "fieldCampCount") prefix = "mission camps"; else if (key == "fieldHospitalCount") prefix = "mission hospitals"; else if (key == "roadblockCount") prefix = "mission roadblocks"; + else if (key == "emptyRestartHours") prefix = "mission emptyhours"; else if (key == "civilianTrafficCount") prefix = "mission traffic"; else if (key == "startMode") prefix = "mission start"; else if (key == "radioDefenceSeconds") prefix = "mission radio"; @@ -134,6 +135,7 @@ class ERR_AdminMenu : ChimeraMenuBase names.Insert("RandomDay_startTimeMinutes"); names.Insert("Default_viewDistanceMetres"); names.Insert("OpenForces"); + names.Insert("RestartEmptyOn"); names.Insert("RestartEmptyOff"); names.Insert("OpenGuards"); names.Insert("GuardsBack"); names.Insert("OpenSites"); names.Insert("SitesBack"); names.Insert("OpenPlane"); names.Insert("PlaneBack"); @@ -255,9 +257,11 @@ class ERR_AdminMenu : ChimeraMenuBase if (page == 2) m_LogPoll = 1; } protected int m_KeepWeaponState = -1; + protected int m_RestartEmptyState = -1; protected void UpdateActions() { ERR_SelectionStyle.Pair(GetRootWidget(), "KeepWeaponOn", "KeepWeaponOff", m_KeepWeaponState); + ERR_SelectionStyle.Pair(GetRootWidget(), "RestartEmptyOn", "RestartEmptyOff", m_RestartEmptyState); foreach (SCR_ButtonBaseComponent button : m_Buttons) { string name = button.GetRootWidget().GetName(); @@ -272,6 +276,8 @@ class ERR_AdminMenu : ChimeraMenuBase else if (name.IndexOf("GuardHQ_") == 0) button.SetEnabled(m_Ready && m_HQ && m_AvailableFields.Contains("objectGuardHQMode")); else if (name == "RerollIntel" || name == "IntelSummary") button.SetEnabled(m_Ready && m_Intel); else if (name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ); + else if (name == "RestartEmptyOn") button.SetEnabled(m_Ready && m_RestartEmptyState == 0); + else if (name == "RestartEmptyOff") button.SetEnabled(m_Ready && m_RestartEmptyState == 1); else if (name == "KeepWeaponOn") button.SetEnabled(m_Ready && m_KeepWeaponState == 0); else if (name == "KeepWeaponOff") button.SetEnabled(m_Ready && m_KeepWeaponState == 1); } @@ -280,7 +286,7 @@ class ERR_AdminMenu : ChimeraMenuBase { if (!authorized) { Close(); return; } if (!values) return; - m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1; + m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1; m_RestartEmptyState = -1; foreach (string entry : values) { if (entry == "supports=hq") { m_HQ = true; continue; } @@ -316,6 +322,14 @@ class ERR_AdminMenu : ChimeraMenuBase ERR_SelectionStyle.Pair(GetRootWidget(), "PlaneOn", "PlaneOff", planeState); m_AvailableFields.Insert(key); continue; } + if (key == "restartOnEmpty") + { + m_RestartEmptyState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt(); + string restartState = "Disabled"; + if (m_RestartEmptyState == 1) restartState = "Enabled"; + SetText("Saved_restartOnEmpty", "Saved: " + restartState); + continue; + } if (key == "keepWeaponOnUnconscious") { m_KeepWeaponState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt(); @@ -464,6 +478,13 @@ class ERR_AdminMenu : ChimeraMenuBase m_ClickDelay = 0.3; SetText("Feedback", "Saving reconnaissance plane selection for next round..."); m_Controller.ERR_AdminSubmit("mission plane set " + enabled, true); return; } + if (name == "RestartEmptyOn" || name == "RestartEmptyOff") + { + if (m_RestartEmptyState < 0) return; + string restartEnabled = "0"; if (name == "RestartEmptyOn") restartEnabled = "1"; + m_ClickDelay = 0.3; SetText("Feedback", "Saving restart after last disconnect..."); + m_Controller.ERR_AdminSubmit("mission restartempty set " + restartEnabled, true); return; + } if (name == "KeepWeaponOn" || name == "KeepWeaponOff") { string retain = "0"; if (name == "KeepWeaponOn") retain = "1"; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c index 5a612f3..9398d57 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c @@ -23,6 +23,8 @@ class ERR_CommandArguments if (section == "mission" && option == "traffic") return "civilianTrafficCount"; if (section == "mission" && option == "start") return "startMode"; if (section == "mission" && option == "keepweapon") return "keepWeaponOnUnconscious"; + if (section == "mission" && option == "restartempty") return "restartOnEmpty"; + if (section == "mission" && option == "emptyhours") return "emptyRestartHours"; if (section == "mission" && option == "radio") return "radioDefenceSeconds"; if (section == "intel" && option == "reveal") return "intelRevealChancePercent"; return ""; @@ -31,8 +33,8 @@ class ERR_CommandArguments { if (key == "aiDoctrine" && text == "current") { value = 0; return true; } if (key == "aiDoctrine" && text == "coordinated") { value = 1; return true; } - if (key == "aiShadow" && text == "off") { value = 0; return true; } - if (key == "aiShadow" && text == "on") { value = 1; return true; } + if ((key == "aiShadow" || key == "restartOnEmpty") && text == "off") { value = 0; return true; } + if ((key == "aiShadow" || key == "restartOnEmpty") && text == "on") { value = 1; return true; } if (key == "startMode" && text == "prison") { value = 0; return true; } if (key == "startMode" && text == "crash") { value = 1; return true; } if (text == "random" && ERR_Settings.Randomizable(key)) { value = -1; return true; } @@ -179,6 +181,8 @@ class ERR_AdminService if (key == "objectGuardCount") return "0..16 soldiers per factory/industrial, military or infrastructure site; default 8, zero off. Next round. HQ participation is configured separately; all report. Towers and chimneys use suitable platforms. Lost guards are not replaced."; if (key == "hunterSpawnDistanceMetres") return "Opening UAZ and troop-truck patrol placement. Applies next round."; if (key == "roadblockCount") return "Set 0..30 roadblocks (default 4; 0 disables). Applies next round. Each has four guards; unsuitable sites are skipped."; + if (key == "restartOnEmpty") return "On (1) restarts after the last disconnect; Off (0) preserves the round for reconnects. Default On. Applies immediately; normal round endings still apply."; + if (key == "emptyRestartHours") return "0 disables idle restarts; 1..168 real hours continuously empty. Default 5. Applies immediately, measured from mission start or the last disconnect. Connected players, including spectators, prevent idle restarts."; if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved."; if (key == "keepWeaponOnUnconscious") return "Set 1 to retain the held firearm during unconsciousness and equip it after recovery; 0 keeps normal dropping. Applies to the next knockout."; if (key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent") @@ -220,6 +224,7 @@ class ERR_AdminService if (topic == "time" || topic == "mission") { replies.Insert("/esc mission radio show|set N|random | mission abandon"); + replies.Insert("/esc mission restartempty show|set on|off | mission emptyhours show|set 0..168; applies now, zero hours disables idle restarts."); replies.Insert("/esc mission doctrine show|set current|coordinated | mission aishadow show|set off|on; next round only."); replies.Insert("/esc mission start show|set prison|crash: save the next round start; current round unchanged."); replies.Insert("/esc mission startweapons show|set 0..12 | mission startammo show|set 0..48; shared backpack, next round."); 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 3ea3f85..ce739c4 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c @@ -75,7 +75,7 @@ class ERR_GameMode : SCR_BaseGameMode protected bool m_StartedPlayers; protected bool m_HadConnectedPlayers; protected bool m_EmptyRestartRequested; - protected int m_EmptyMissionStartedAt; + protected int m_EmptySince; protected ref map m_ExhaustedInitialAdmissions = new map(); protected bool m_Stopped; protected bool m_NativeEndHandled; @@ -458,11 +458,12 @@ class ERR_GameMode : SCR_BaseGameMode { if (!Replication.IsServer() || m_Stopped || m_NativeEndHandled) return; m_HadConnectedPlayers = true; + m_EmptySince = System.GetTickCount(); } protected bool CheckEmptyServerRestart() { - if (!Replication.IsServer() || m_Stopped || m_NativeEndHandled) return false; + if (!Replication.IsServer() || m_Stopped || m_NativeEndHandled || !m_Settings) return false; if (m_EmptyRestartRequested) return true; PlayerManager manager = GetGame().GetPlayerManager(); if (!manager) return false; @@ -470,13 +471,16 @@ class ERR_GameMode : SCR_BaseGameMode if (manager.GetPlayerCount() > 0) { m_HadConnectedPlayers = true; + m_EmptySince = System.GetTickCount(); return false; } // Real elapsed milliseconds: mission clock settings cannot accelerate this. - if (!m_HadConnectedPlayers && System.GetTickCount() - m_EmptyMissionStartedAt < 18000000) return false; + bool afterDisconnect = m_HadConnectedPlayers && m_Settings.Get("restartOnEmpty") == 1; + int hours = m_Settings.Get("emptyRestartHours"); + if (!afterDisconnect && (hours == 0 || System.GetTickCount() - m_EmptySince < hours * 3600000)) 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."; + m_EndReason = string.Format("The server has been empty for %1 hours. Restarting the mission.", hours); + if (afterDisconnect) 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. @@ -492,7 +496,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_EmptySince = System.GetTickCount(); m_Settings = new ERR_Settings(SettingsPath()); if (!System.IsCLIParam("errNoPerf")) m_Performance = new ERR_Performance(); PublishViewDistance(); diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c index a84f22f..e6930c3 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c @@ -9,7 +9,7 @@ class ERR_Settings static array Keys() { - return {"aiDoctrine", "aiShadow", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; + return {"aiDoctrine", "aiShadow", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; } static bool Valid(string key, int value) @@ -31,7 +31,8 @@ class ERR_Settings if (key == "roadblockCount") return value >= 0 && value <= 30; if (key == "civilianTrafficCount") return value >= 0 && value <= 6; if (key == "startMode") return value == 0 || value == 1; - if (key == "keepWeaponOnUnconscious") return value == 0 || value == 1; + if (key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return value == 0 || value == 1; + if (key == "emptyRestartHours") return value >= 0 && value <= 168; if (key == "heliTimerMinutes") return value >= 1 && value <= 1440; if (key == "convoyCount") return value >= 0 && value <= 20; if (key == "automaticConvoyEnabled") return value == 0 || value == 1; @@ -132,7 +133,8 @@ class ERR_Settings if (key == "roadblockCount") return 4; if (key == "civilianTrafficCount") return 3; if (key == "startMode") return 0; - if (key == "keepWeaponOnUnconscious") return 1; + if (key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return 1; + if (key == "emptyRestartHours") return 5; if (key == "heliTimerMinutes") return 15; if (key == "convoyCount") return 3; if (key == "automaticConvoyEnabled") return 1; @@ -167,6 +169,8 @@ class ERR_Settings m_Values.Set("civilianTrafficCount", AddedDefault("civilianTrafficCount")); m_Values.Set("startMode", AddedDefault("startMode")); m_Values.Set("keepWeaponOnUnconscious", AddedDefault("keepWeaponOnUnconscious")); + m_Values.Set("restartOnEmpty", AddedDefault("restartOnEmpty")); + m_Values.Set("emptyRestartHours", AddedDefault("emptyRestartHours")); m_Values.Set("reportIntervalSeconds", 10); m_Values.Set("supportSquadLimit", 4); m_Values.Set("responseRadiusMetres", 2000); diff --git a/addons/core/UI/layouts/ERR/AdminMenu.layout b/addons/core/UI/layouts/ERR/AdminMenu.layout index 210bd54..b723388 100644 --- a/addons/core/UI/layouts/ERR/AdminMenu.layout +++ b/addons/core/UI/layouts/ERR/AdminMenu.layout @@ -21,37 +21,47 @@ FrameWidgetClass { Color 0.035 0.05 0.065 0.98 } FrameWidgetClass { Name "SettingsPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { -TextWidgetClass { Name "Label_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 456 OffsetRight -484 SizeY 26 OffsetBottom -32 } Color 0.9 0.92 0.94 1 Text "Next start (Prison or Crash)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 38 OffsetTop 38 SizeX 180 OffsetRight -208 SizeY 38 OffsetBottom -76 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 38 OffsetTop 38 SizeX 100 OffsetRight -320 SizeY 38 OffsetBottom -76 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -TextWidgetClass { Name "Saved_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 82 OffsetTop 82 SizeX 456 OffsetRight -484 SizeY 40 OffsetBottom -122 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Help_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 125 OffsetTop 125 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -153 } Color 0.9 0.92 0.94 1 Text "Starting scenario for the next round." "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Label_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 6 OffsetTop 6 SizeX 456 OffsetRight -964 SizeY 26 OffsetBottom -32 } Color 0.9 0.92 0.94 1 Text "Radio defence (30-1800 seconds)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 38 OffsetTop 38 SizeX 180 OffsetRight -688 SizeY 38 OffsetBottom -76 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 38 OffsetTop 38 SizeX 100 OffsetRight -800 SizeY 38 OffsetBottom -76 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Random_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 812 OffsetLeft 812 PositionY 38 OffsetTop 38 SizeX 140 OffsetRight -952 SizeY 38 OffsetBottom -76 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Random" } } } -TextWidgetClass { Name "Saved_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 82 OffsetTop 82 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -106 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Help_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 109 OffsetTop 109 SizeX 456 OffsetRight -964 SizeY 36 OffsetBottom -145 } Color 0.9 0.92 0.94 1 Text "Time to defend the radio." "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Label_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 160 OffsetTop 160 SizeX 456 OffsetRight -484 SizeY 26 OffsetBottom -186 } Color 0.9 0.92 0.94 1 Text "Starting pistols (0-12)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 192 OffsetTop 192 SizeX 180 OffsetRight -208 SizeY 38 OffsetBottom -230 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 192 OffsetTop 192 SizeX 100 OffsetRight -320 SizeY 38 OffsetBottom -230 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -TextWidgetClass { Name "Saved_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 236 OffsetTop 236 SizeX 456 OffsetRight -484 SizeY 24 OffsetBottom -260 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Help_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 263 OffsetTop 263 SizeX 456 OffsetRight -484 SizeY 36 OffsetBottom -299 } Color 0.9 0.92 0.94 1 Text "Shared prison/crash backpack; next round." "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Label_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 160 OffsetTop 160 SizeX 456 OffsetRight -964 SizeY 26 OffsetBottom -186 } Color 0.9 0.92 0.94 1 Text "Starting spare magazines (0-48)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 192 OffsetTop 192 SizeX 180 OffsetRight -688 SizeY 38 OffsetBottom -230 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 192 OffsetTop 192 SizeX 100 OffsetRight -800 SizeY 38 OffsetBottom -230 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -TextWidgetClass { Name "Saved_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 236 OffsetTop 236 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -260 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Help_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 263 OffsetTop 263 SizeX 456 OffsetRight -964 SizeY 36 OffsetBottom -299 } Color 0.9 0.92 0.94 1 Text "Shared backpack; 8 rounds per magazine. Next round." "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "KeepWeaponLabel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 314 OffsetTop 314 SizeX 456 OffsetRight -484 SizeY 26 OffsetBottom -340 } Color 0.9 0.92 0.94 1 Text "Keep held weapon when unconscious" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "KeepWeaponOn" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 346 OffsetTop 346 SizeX 140 OffsetRight -168 SizeY 38 OffsetBottom -384 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Enabled" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "KeepWeaponOff" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 180 OffsetLeft 180 PositionY 346 OffsetTop 346 SizeX 152 OffsetRight -332 SizeY 38 OffsetBottom -384 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Disabled" } } } -TextWidgetClass { Name "Saved_keepWeaponOnUnconscious" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 390 OffsetTop 390 SizeX 456 OffsetRight -484 SizeY 24 OffsetBottom -414 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "MissionHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 417 OffsetTop 417 SizeX 456 OffsetRight -484 SizeY 40 OffsetBottom -457 } Color 0.9 0.92 0.94 1 Text "Applies next knockout; existing casualties keep their rule." "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Label_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 314 OffsetTop 314 SizeX 456 OffsetRight -964 SizeY 26 OffsetBottom -340 } Color 0.9 0.92 0.94 1 Text "Civilian traffic (0-6 cars; 0 off)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 346 OffsetTop 346 SizeX 180 OffsetRight -688 SizeY 38 OffsetBottom -384 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 346 OffsetTop 346 SizeX 100 OffsetRight -800 SizeY 38 OffsetBottom -384 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -TextWidgetClass { Name "Saved_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 390 OffsetTop 390 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -414 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Help_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 417 OffsetTop 417 SizeX 456 OffsetRight -964 SizeY 36 OffsetBottom -453 } Color 0.9 0.92 0.94 1 Text "0 disables new traffic. Player-used cars are kept." "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 4 OffsetTop 4 SizeX 456 OffsetRight -484 SizeY 22 OffsetBottom -26 } Color 0.9 0.92 0.94 1 Text "Next start (Prison or Crash)" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 28 OffsetTop 28 SizeX 180 OffsetRight -208 SizeY 32 OffsetBottom -60 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 28 OffsetTop 28 SizeX 100 OffsetRight -320 SizeY 32 OffsetBottom -60 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 64 OffsetTop 64 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -92 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 92 OffsetTop 92 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -120 } Color 0.9 0.92 0.94 1 Text "Starting scenario for the next round." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 4 OffsetTop 4 SizeX 456 OffsetRight -964 SizeY 22 OffsetBottom -26 } Color 0.9 0.92 0.94 1 Text "Radio defence (30-1800 seconds)" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 28 OffsetTop 28 SizeX 180 OffsetRight -688 SizeY 32 OffsetBottom -60 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 28 OffsetTop 28 SizeX 100 OffsetRight -800 SizeY 32 OffsetBottom -60 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Random_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 812 OffsetLeft 812 PositionY 28 OffsetTop 28 SizeX 140 OffsetRight -952 SizeY 32 OffsetBottom -60 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Random" } } } +TextWidgetClass { Name "Saved_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 64 OffsetTop 64 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -92 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 92 OffsetTop 92 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -120 } Color 0.9 0.92 0.94 1 Text "Time to defend the radio." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 124 OffsetTop 124 SizeX 456 OffsetRight -484 SizeY 22 OffsetBottom -146 } Color 0.9 0.92 0.94 1 Text "Starting pistols (0-12)" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 148 OffsetTop 148 SizeX 180 OffsetRight -208 SizeY 32 OffsetBottom -180 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 148 OffsetTop 148 SizeX 100 OffsetRight -320 SizeY 32 OffsetBottom -180 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 184 OffsetTop 184 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -212 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_startWeaponCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 212 OffsetTop 212 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -240 } Color 0.9 0.92 0.94 1 Text "Shared prison/crash backpack; next round." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 124 OffsetTop 124 SizeX 456 OffsetRight -964 SizeY 22 OffsetBottom -146 } Color 0.9 0.92 0.94 1 Text "Starting spare magazines (0-48)" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 148 OffsetTop 148 SizeX 180 OffsetRight -688 SizeY 32 OffsetBottom -180 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 148 OffsetTop 148 SizeX 100 OffsetRight -800 SizeY 32 OffsetBottom -180 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 184 OffsetTop 184 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -212 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_startMagazineCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 212 OffsetTop 212 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -240 } Color 0.9 0.92 0.94 1 Text "Shared backpack; 8 rounds per magazine. Next round." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "KeepWeaponLabel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 244 OffsetTop 244 SizeX 456 OffsetRight -484 SizeY 22 OffsetBottom -266 } Color 0.9 0.92 0.94 1 Text "Keep held weapon when unconscious" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "KeepWeaponOn" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 268 OffsetTop 268 SizeX 140 OffsetRight -168 SizeY 32 OffsetBottom -300 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Enabled" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "KeepWeaponOff" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 180 OffsetLeft 180 PositionY 268 OffsetTop 268 SizeX 152 OffsetRight -332 SizeY 32 OffsetBottom -300 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Disabled" } } } +TextWidgetClass { Name "Saved_keepWeaponOnUnconscious" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 304 OffsetTop 304 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -332 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "MissionHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 332 OffsetTop 332 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -360 } Color 0.9 0.92 0.94 1 Text "Applies next knockout; existing casualties keep their rule." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 244 OffsetTop 244 SizeX 456 OffsetRight -964 SizeY 22 OffsetBottom -266 } Color 0.9 0.92 0.94 1 Text "Civilian traffic (0-6 cars; 0 off)" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 268 OffsetTop 268 SizeX 180 OffsetRight -688 SizeY 32 OffsetBottom -300 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 268 OffsetTop 268 SizeX 100 OffsetRight -800 SizeY 32 OffsetBottom -300 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 304 OffsetTop 304 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -332 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_civilianTrafficCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 332 OffsetTop 332 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -360 } Color 0.9 0.92 0.94 1 Text "0 disables new traffic. Player-used cars are kept." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_restartOnEmpty" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 364 OffsetTop 364 SizeX 456 OffsetRight -484 SizeY 22 OffsetBottom -386 } Color 0.9 0.92 0.94 1 Text "Restart after last disconnect" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "RestartEmptyOn" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 388 OffsetTop 388 SizeX 140 OffsetRight -168 SizeY 32 OffsetBottom -420 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Enabled" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "RestartEmptyOff" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 180 OffsetLeft 180 PositionY 388 OffsetTop 388 SizeX 152 OffsetRight -332 SizeY 32 OffsetBottom -420 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Disabled" } } } +TextWidgetClass { Name "Saved_restartOnEmpty" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 424 OffsetTop 424 SizeX 456 OffsetRight -484 SizeY 24 OffsetBottom -448 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_restartOnEmpty" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 452 OffsetTop 452 SizeX 456 OffsetRight -484 SizeY 28 OffsetBottom -480 } Color 0.9 0.92 0.94 1 Text "Applies now. Normal round endings still restart." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 364 OffsetTop 364 SizeX 456 OffsetRight -964 SizeY 22 OffsetBottom -386 } Color 0.9 0.92 0.94 1 Text "Restart while empty (hours)" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 388 OffsetTop 388 SizeX 180 OffsetRight -688 SizeY 32 OffsetBottom -420 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 700 OffsetLeft 700 PositionY 388 OffsetTop 388 SizeX 100 OffsetRight -800 SizeY 32 OffsetBottom -420 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 424 OffsetTop 424 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -448 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 452 OffsetTop 452 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -480 } Color 0.9 0.92 0.94 1 Text "0 off; 1-168 real hours empty. Default 5. Applies now." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } } } FrameWidgetClass { Name "ForcesPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { TextWidgetClass { diff --git a/docs/configuration.md b/docs/configuration.md index 9f43b4c..e056fb7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -60,6 +60,25 @@ requested totals instead of preventing mission startup. The admin Status page also shows these totals. This is an interim checkpoint system, independent of the existing HQ-directed road interceptions. +## Mission restarts + +The admin menu **Mission** page includes two independent settings: + +- **Restart after last disconnect**: Enabled or Disabled, default Enabled. +- **Restart while empty (hours)**: 0 disables the idle timer; 1–168 sets the + number of real hours continuously empty. Default 5. Press **Apply** to save. + +Both apply immediately and persist in `$profile:Escapists.json` as +`mission.restartOnEmpty` and `mission.emptyRestartHours`. Older configurations +receive the enabled/five-hour defaults. The idle interval starts with a new +mission or the last disconnect and resets whenever players connect. Connected +spectators and players loading in also prevent an empty-server restart. +Changing the interval uses the time already spent empty. Normal victory/defeat +and no-survivor endings still apply when these options are disabled. + +Chat equivalents are `/esc mission restartempty set on|off` and +`/esc mission emptyhours set N`; both also support `show` and `help`. + ## One server configuration All mission preferences now live in **`$profile:Escapists.json`** on the server. diff --git a/docs/player-lifecycle.md b/docs/player-lifecycle.md index 87974a0..cf3ddff 100644 --- a/docs/player-lifecycle.md +++ b/docs/player-lifecycle.md @@ -70,21 +70,29 @@ The server's scene deadline now reaches the existing owned-ticket rejection even ## Empty-server restarts (14 October 2026) -When the last player disconnects, the next server tick ends the mission through +By default, 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. +body and reconnect behavior still applies. Administrators can disable this +behavior using **Mission → Restart after last disconnect** to preserve the +round for reconnects, subject to the idle timer and normal round endings. 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 by default. **Mission → Restart while empty (hours)** accepts 0 to disable +the timer or 1–168 hours. The interval starts at mission initialization or the +last disconnect, and is reset while players are connected. Both controls save +immediately for this and future missions; interval changes use the elapsed empty +time rather than restarting the timer. 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_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. +wrapper, covering independent disabled/enabled settings, interval boundaries, +live changes, repeated empty rounds, last disconnect, connections between ticks, +preparation, authority and duplicate-end prevention. Settings and menu tests +cover migration defaults, persistence, invalid values, failed saves and routing. 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_admin_menu.py b/tests/test_admin_menu.py index 78be517..dfe3a89 100644 --- a/tests/test_admin_menu.py +++ b/tests/test_admin_menu.py @@ -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 Keys(')) settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array 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> 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 +#include +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'): diff --git a/tests/test_admin_setting_policy.py b/tests/test_admin_setting_policy.py index 15d1102..fe3d570 100644 --- a/tests/test_admin_setting_policy.py +++ b/tests/test_admin_setting_policy.py @@ -13,6 +13,12 @@ run('#include \n#include \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)); diff --git a/tests/test_empty_server_restart.py b/tests/test_empty_server_restart.py index 808212f..346642f 100644 --- a/tests/test_empty_server_restart.py +++ b/tests/test_empty_server_restart.py @@ -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 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') diff --git a/tests/test_settings_migration.py b/tests/test_settings_migration.py index fb6011e..d45565d 100644 --- a/tests/test_settings_migration.py +++ b/tests/test_settings_migration.py @@ -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}) diff --git a/tests/test_start_settings.py b/tests/test_start_settings.py index 845c6b7..1e64b03 100644 --- a/tests/test_start_settings.py +++ b/tests/test_start_settings.py @@ -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. diff --git a/tools/import_settings.py b/tools/import_settings.py index d618f3e..6f0fb2c 100644 --- a/tools/import_settings.py +++ b/tools/import_settings.py @@ -13,6 +13,8 @@ SCHEMA={ 'civilianTrafficCount':(3,0,6), 'startMode':(0,0,1), 'keepWeaponOnUnconscious':(1,0,1), + 'restartOnEmpty':(1,0,1), + 'emptyRestartHours':(5,0,168), 'reportIntervalSeconds':(10,5,60), 'supportSquadLimit':(4,0,8), 'responseRadiusMetres':(2000,500,5000),