--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c @@ -9,11 +9,12 @@ static array Keys() { - return {"keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; + return {"startMode", "keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; } static bool Valid(string key, int value) { + if (key == "startMode") return value == 0 || value == 1; if (key == "keepWeaponOnUnconscious") return value == 0 || value == 1; if (key == "heliTimerMinutes") return value >= 1 && value <= 1440; if (key == "convoyCount") return value >= 0 && value <= 20; @@ -98,6 +99,7 @@ static int AddedDefault(string key) { + if (key == "startMode") return 0; if (key == "keepWeaponOnUnconscious") return 1; if (key == "heliTimerMinutes") return 15; if (key == "convoyCount") return 3; @@ -114,6 +116,7 @@ void ERR_Settings(string path = PATH) { m_Path = path; + m_Values.Set("startMode", AddedDefault("startMode")); m_Values.Set("keepWeaponOnUnconscious", AddedDefault("keepWeaponOnUnconscious")); m_Values.Set("reportIntervalSeconds", 10); m_Values.Set("supportSquadLimit", 4); --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c @@ -4,6 +4,7 @@ { static string CoreSetting(string section, string option) { + if (section == "mission" && option == "start") return "startMode"; if (section == "mission" && option == "keepweapon") return "keepWeaponOnUnconscious"; if (section == "mission" && option == "radio") return "radioDefenceSeconds"; if (section == "intel" && option == "reveal") return "intelRevealChancePercent"; @@ -11,6 +12,8 @@ } static bool Selection(string key, string text, out int value) { + 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; } if ((text == "random-day" || text == "random day") && key == "startTimeMinutes") { value = -2; return true; } if (key == "startTimeMinutes") return ERR_TimeService.ParseClock(text, value); @@ -99,6 +102,20 @@ else replies.Insert("Use /esc mission viewdistance show|set 500..10000|default. Invalid value or save failure leaves the previous setting unchanged."); return true; } + protected bool StartCommand(ERR_GameMode mode, array parts, array replies) + { + if (parts.Count() < 2 || parts[0] != "mission" || parts[1] != "start") return false; + if (parts.Count() == 3 && parts[2] == "show") + { + replies.Insert(ERR_AdminMenuFields.StartSummary(mode.Settings().Get("startMode"), mode.StartMode())); + return true; + } + int value; + if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Selection("startMode", parts[3], value) && mode.Settings().Set("startMode", value)) + replies.Insert(ERR_AdminMenuFields.StartSummary(value, mode.StartMode()) + ". Saved for next round; current start and players are unchanged."); + else replies.Insert("Use /esc mission start show|set prison|crash (or 0|1). No random start. Invalid value or save failure leaves the previous selection unchanged."); + return true; + } protected bool TimeCommand(ERR_GameMode mode, array parts, array replies) { if (parts.Count() < 2 || parts[0] != "mission" || (parts[1] != "time" && parts[1] != "speed")) return false; @@ -154,6 +171,7 @@ if (topic == "time" || topic == "mission") { replies.Insert("/esc mission radio show|set N|random | mission abandon"); + replies.Insert("/esc mission start show|set prison|crash: save the next round start; current round unchanged."); replies.Insert("/esc mission viewdistance show|set 500..10000|default; applies immediately after closing menus."); replies.Insert("/esc time show|set HH:MM|random|random-day | mission speed show|set N|random; saved changes apply next round."); replies.Insert("/esc mission date show|set YYYY-MM-DD|authored|random | mission weather show|set authored|clear|overcast|rain|random"); @@ -173,6 +191,7 @@ replies.Insert("/esc menu | status | settings | help mission|time|intel|hq|cheat|god|killais"); replies.Insert("/esc mission abandon | mission radio show|set N|random | time show|set HH:MM|random|random-day"); replies.Insert("/esc mission date|weather|speed show|set VALUE | mission viewdistance show|set 500..10000|default | intel reveal show|set N|help"); + replies.Insert("/esc mission start show|set prison|crash; next round only."); replies.Insert("/esc spectate next|previous; optional systems add their own command topics."); } protected bool Unavailable(array parts, array replies) @@ -212,6 +231,7 @@ foreach (string key : ERR_Settings.Keys()) { string line = key + "=" + mode.Settings().Get(key).ToString(); + if (key == "startMode") line = ERR_AdminMenuFields.StartSummary(mode.Settings().Get(key), mode.StartMode()); if (key == "automaticConvoyEnabled") line += " (saved for next round; current startup unchanged)"; replies.Insert(line); } @@ -240,6 +260,7 @@ if (ERR_EnvironmentCommands.Execute(mode.Settings(), mode.Environment(), parts, replies)) return; if (ViewDistanceCommand(mode, parts, replies)) return; if (TimeCommand(mode, parts, replies)) return; + if (StartCommand(mode, parts, replies)) return; if (parts.Count() == 3 || parts.Count() == 4) { string key = Setting(parts[0], parts[1]); --- 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 @@ { static array Keys() { - return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"}; + return {"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) { @@ -30,16 +30,22 @@ static string Display(string key, string value) { if (value == "-1" || value == "-2") return ERR_Settings.SelectionText(value.ToInt()); + if (key == "startMode") { if (value == "1") return "Crash"; return "Prison"; } if (key == "startTimeMinutes") return ERR_TimeService.Clock(value.ToInt()); if (key == "startDateCode") return ERR_EnvironmentService.Date(value.ToInt()); if (key == "weatherProfile") return ERR_EnvironmentService.ProfileName(value.ToInt()); if (key == "viewDistanceMetres" && value == "0") return "default"; return value; } + static string StartSummary(int saved, int current) + { + return "Next start: " + Display("startMode", saved.ToString()) + " | Current round: " + Display("startMode", current.ToString()); + } static string Command(string key, string value) { string prefix; - if (key == "radioDefenceSeconds") prefix = "mission radio"; + if (key == "startMode") prefix = "mission start"; + else if (key == "radioDefenceSeconds") prefix = "mission radio"; else if (key == "aiIntelChancePercent") prefix = "intel carrying"; else if (key == "intelRevealChancePercent") prefix = "intel reveal"; else if (key == "startTimeMinutes") prefix = "mission time"; @@ -258,7 +264,8 @@ if (!ERR_AdminMenuFields.Keys().Contains(key)) continue; string value = ERR_AdminMenuFields.Display(key, entry.Substring(divider + 1, entry.Length() - divider - 1)); m_AvailableFields.Insert(key); - SetText("Saved_" + key, "Saved: " + value); + if (key == "startMode") SetText("Saved_" + key, ERR_AdminMenuFields.StartSummary(entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt(), m_Mode.StartMode())); + else SetText("Saved_" + key, "Saved: " + value); // Existing fields retain all draft text, including a temporarily empty edit. if (!m_InitializedFields.Contains(key)) { --- a/addons/core/UI/layouts/ERR/AdminMenu.layout +++ b/addons/core/UI/layouts/ERR/AdminMenu.layout @@ -22,10 +22,39 @@ } 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 "SettingsHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } - Color 0.9 0.92 0.94 1 Text "Type a value or choose Random, then press Apply. Random stays saved and rolls once each round." "Font Size" 19 "Min Font Size" 19 Wrap 1 - FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } -} + Name "Label_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -34 } + 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" + } + } +TextWidgetClass { + Name "Saved_startMode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 38 OffsetTop 38 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -62 } + Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "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 62 OffsetTop 62 SizeX 936 OffsetRight -964 SizeY 18 OffsetBottom -80 } + Color 0.72 0.81 0.87 1 Text "Crash starts on foot beside a static wreck. Save for next round; no flight or random start." "Font Size" 16 "Min Font Size" 16 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 408 OffsetLeft 408 PositionY 6 OffsetTop 6 SizeX 240 OffsetRight -648 SizeY 32 OffsetBottom -38 } + 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 660 OffsetLeft 660 PositionY 6 OffsetTop 6 SizeX 112 OffsetRight -772 SizeY 32 OffsetBottom -38 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Apply" + } + } + } TextWidgetClass { Name "Label_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 80 OffsetTop 80 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -108 } Color 0.9 0.92 0.94 1 Text "Radio transmission defence (30-1800 s)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { --- a/tools/import_settings.py +++ b/tools/import_settings.py @@ -3,6 +3,7 @@ import argparse,json,os,tempfile,sys from pathlib import Path SCHEMA={ + 'startMode':(0,0,1), 'keepWeaponOnUnconscious':(1,0,1), 'reportIntervalSeconds':(10,5,60), 'supportSquadLimit':(4,0,8), --- a/tests/test_admin_menu.py +++ b/tests/test_admin_menu.py @@ -6,11 +6,11 @@ from pathlib import Path BASE=Path(os.environ.get("ERR_CONVOY_SETTINGS_ROOT", Path(__file__).resolve().parents[1])) from policy_harness import ROOT, body, run -source = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c' -layout = ROOT / 'addons/core/UI/layouts/ERR/AdminMenu.layout' +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) == 15 and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "automaticEquipmentChancePercent"} +assert len(keys) == 16 and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "automaticEquipmentChancePercent"} 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. @@ -47,7 +47,9 @@ assert 'RandomDay_startTimeMinutes' in names 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, ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key, 'Help_' + key)) + assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key, 'Help_' + key)) + if key != 'startMode': 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 for name in re.findall(r'"([A-Za-z]+)"', body(source, 'override void OnMenuOpen(').split('foreach')[0]): @@ -93,7 +95,7 @@ assert(Fields::FollowJournal(false,0,200,400,false)); assert(Fields::FollowJournal(true,0,0,400,false)); std::vector> cases={ - {"radioDefenceSeconds","mission radio"},{"aiIntelChancePercent","intel carrying"}, + {"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"}, @@ -120,7 +122,7 @@ for(string key:{"intelRevealChancePercent","radioDefenceSeconds","startTimeMinutes","timeMultiplier","startDateCode","weatherProfile"}) assert(Fields::Available(key,false,false)); } ''') -print('PASS: actual menu command routing covers all fifteen settings, rejects unsupported keys, gates companions, and matches unique authored widgets') +print('PASS: actual menu command routing covers all sixteen settings, rejects unsupported keys, gates companions, and matches unique authored widgets') assert all(name in names for name in ('KeepWeaponOn', 'KeepWeaponOff', 'Saved_keepWeaponOnUnconscious')) click=body(source,'protected void OnButton(') --- a/tests/test_settings_migration.py +++ b/tests/test_settings_migration.py @@ -5,7 +5,7 @@ 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(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(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 deferred==[] for count in (0,1,3,20): values,notes=m.merge(ai={'convoyCount':count}) --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c @@ -11,7 +11,7 @@ } static string Extraction(float radius) { - return string.Format("The map marks the initial helicopter pickup site. A prisoner must pilot the helicopter. Gather everyone you intend to rescue before flying beyond %1 kilometres from the prison: crossing that boundary ends the round for everyone. Only living prisoners aboard escape, including unconscious passengers; anyone left behind does not escape. To load an unconscious prisoner, place them on the ground beside the grounded, stationary helicopter and use 'Load casualty into extraction helicopter' on them.", radius / 1000.0); + return string.Format("The map marks the initial helicopter pickup site. A prisoner must pilot the helicopter. Gather everyone you intend to rescue before flying beyond %1 kilometres from the starting area: crossing that boundary ends the round for everyone. Only living prisoners aboard escape, including unconscious passengers; anyone left behind does not escape. To load an unconscious prisoner, place them on the ground beside the grounded, stationary helicopter and use 'Load casualty into extraction helicopter' on them.", radius / 1000.0); } static string RadioFeedbackMessage(ERR_RadioFeedback feedback) { @@ -55,7 +55,9 @@ // Scan all sites before acknowledging rough intel: an identified radio wins. if (roughRadio) return "Intel recovered: a question mark shows an approximate location, but its type is still unknown. Take a different intelligence folder into your inventory to pinpoint the radio; a folder already recovered cannot provide another clue. Try the other marked clue site if needed. A close approach with a clear view can also identify the station."; - return "Choose either marked clue site. Take its intelligence folders into your own inventory to reveal the station on your map. If the location is not yet identified, take another folder. Each marked site has enough folders; use the other if one is lost. If every folder is lost, a close approach with a clear view can identify the station itself. Nearby medical and guard sites can also be discovered by approaching with a clear view."; + string startHint; + if (mode.StartMode() == 1) startHint = "Search the salvage backpack at the crash site for weapons and a field map. "; + return startHint + "Choose either marked clue site. Take its intelligence folders into your own inventory to reveal the station on your map. If the location is not yet identified, take another folder. Each marked site has enough folders; use the other if one is lost. If every folder is lost, a close approach with a clear view can identify the station itself. Nearby medical and guard sites can also be discovered by approaching with a clear view."; } if (phase == ERR_Phase.RADIO_DEFENCE) { --- a/tests/test_rough_intel_feedback.py +++ b/tests/test_rough_intel_feedback.py @@ -1,7 +1,9 @@ """Run actual shared objective selection and map grid methods; no native UI claim.""" from policy_harness import ROOT, body, run +from pathlib import Path +import os -source = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c' +source = Path(os.environ.get('ERR_OBJECTIVE_TEXT_SOURCE', ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c')) s = body(source, 'protected static string PhaseDescription(') s = s.replace('protected ', '').replace('ERR_GameMode mode,', 'ERR_GameMode* mode,') s = s.replace('ERR_Phase phase', 'int phase').replace('mode.', 'mode->') @@ -25,6 +27,7 @@ namespace Math {float Ceil(float v){return std::ceil(v);}float Floor(float v){return std::floor(v);}float Round(float v){return std::round(v);}} struct ERR_LocationComponent {int kind,knowledge;int Kind(){return kind;}int Knowledge(){return knowledge;}}; struct ERR_GameMode { + int startMode=0;int StartMode(){return startMode;} int phase=ERR_Phase::RADIO_SEARCH;string error;bool occupied=false; std::vector locations; int Phase(){return phase;}string PreparationError(){return error;} @@ -44,6 +47,9 @@ mode.locations={&radio}; string unknown=Actual::PhaseDescription(&mode,true); assert(has(unknown,"Choose either marked clue site")&&!has(unknown,"Intel recovered")); + mode.startMode=1;string crash=Actual::PhaseDescription(&mode,true); + assert(has(crash,"salvage backpack at the crash site")&&has(crash,unknown)&&!has(crash,"inside the prison")); + assert(Actual::PhaseDescription(&mode,false)==crash);mode.startMode=0; radio.knowledge=1; string rough=Actual::PhaseDescription(&mode,true); assert(rough!=unknown&&has(rough,"Intel recovered")&&has(rough,"question mark")); @@ -83,3 +89,6 @@ } ''') print('PASS: actual rough-intel objective selection, exact precedence, source grid dimensions and unchanged other-phase dispatch; native UI pending') + +assert "kilometres from the starting area:" in body(source,"static string Extraction(") +assert "kilometres from the prison:" not in body(source,"static string Extraction(") --- a/tests/test_radio_waiting_text.py +++ b/tests/test_radio_waiting_text.py @@ -1,7 +1,9 @@ """Execute actual task/guide copy for initial waiting and subsequent radio states.""" from policy_harness import ROOT, body, run +from pathlib import Path +import os -source = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c' +source = Path(os.environ.get('ERR_OBJECTIVE_TEXT_SOURCE', ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c')) def translate(s): s = s.replace('protected ', '').replace('ERR_GameMode mode,', 'ERR_GameMode* mode,') s = s.replace('ERR_Phase phase', 'int phase').replace('mode.', 'mode->') @@ -27,6 +29,7 @@ namespace Math {float Ceil(float n){return std::ceil(n);}} struct ERR_LocationComponent {int Kind(){return 0;}int Knowledge(){return 0;}}; struct ERR_GameMode { + int startMode=0;int StartMode(){return startMode;} int phase=ERR_Phase::RADIO_DEFENCE, feedback=ERR_RadioFeedback::WAITING, remaining=180; bool occupied=false;int Phase(){return phase;}string PreparationError(){return "";} void Locations(std::vector& locations){locations.clear();} --- /dev/null +++ b/tests/test_start_settings.py @@ -0,0 +1,81 @@ +"""Actual start setting parse/save and admin command bodies; native IO/UI are doubled. +Frozen-mode lifecycle itself is tested by the separate core start fixture. +""" +import os,re +from pathlib import Path +from policy_harness import ROOT,body,run +BASE=Path(os.environ.get('ERR_START_SETTINGS_ROOT',ROOT));CORE=BASE/'addons/core/Scripts/Game/ERR/00_Core' +S=Path(os.environ.get('ERR_SETTINGS_SOURCE', CORE/'ERR_Settings.c'));A=CORE/'ERR_AdminService.c';M=CORE/'ERR_AdminMenu.c' +def cpp(s): + s=s.replace('protected ','').replace('out int ','int& ').replace('out map values','Values& values').replace('new map()','Values()').replace('map','Values') + s=s.replace('array parts','Array parts').replace('array replies','Array& replies').replace('array','Array').replace('foreach (','for (') + s=s.replace('JsonLoadContext json = new JsonLoadContext();','JsonLoadContext json;').replace('JsonSaveContext json = new JsonSaveContext();','JsonSaveContext json;').replace('JsonLoadContext json,','JsonLoadContext& json,') + s=s.replace('ERR_GameMode mode','Mode* mode').replace('mode.','mode->').replace('Settings().','Settings()->').replace('Math.Round','std::round') + for n in ['ERR_Settings','ERR_ProfileConfig','ERR_CommandArguments','ERR_AdminMenuFields','ERR_TimeService','ERR_EnvironmentService','Replication','FileIO']:s=s.replace(n+'.',n+'::') + s=s.replace('"0123456789".IndexOf','string("0123456789").IndexOf') + for n in ['value','saved','current']:s=s.replace(n+'.ToString()','string(std::to_string('+n+'))') + return s +settings='\n'.join(cpp(body(S,sig)) for sig in ['static array Keys(','static bool Valid(','static bool Randomizable(','static bool ValidSelection(','static string SelectionText(','static int AddedDefault(','protected bool Read(','static bool Parse(','bool Set(']) +args='\n'.join(cpp(body(A,sig)) for sig in ['static bool Integer(','static bool Selection(']) +fields='\n'.join(cpp(body(M,sig)) for sig in ['static string Display(','static string StartSummary(']) +command=cpp(body(A,'protected bool StartCommand(')) +run(r''' +#include +#include +#include +#include +#include +#include +struct string:std::string{using std::string::string;string(std::string s):std::string(s){}bool IsEmpty(){return empty();}int Length(){return size();}int ToInt(){return std::stoi(*this);}int IndexOf(string x){auto n=find(x);return n==npos?-1:int(n);}string Substring(int a,int n){return substr(a,n);}}; +templatestruct Array:std::vector{using std::vector::vector;int Count(){return this->size();}void Insert(T v){this->push_back(v);}}; +struct Values{std::map data;int Get(string k){return data.at(k);}void Set(string k,int v){data[k]=v;}void Remove(string k){data.erase(k);}}; +std::map> disk;bool failSave=false; +struct JsonLoadContext{std::map data;bool DoesKeyExist(string k){return data.count(k);}bool ReadValue(string k,float& v){if(!data.count(k))return false;v=data[k];return true;}}; +struct JsonSaveContext{std::map data;void EnableTypeDiscriminator(bool){}void WriteValue(string k,int v){data[k]=v;}bool SaveToFile(string p){if(failSave)return false;disk[p]=data;return true;}}; +namespace ERR_ProfileConfig{bool Load(string p,JsonLoadContext& j){if(!disk.count(p))return false;j.data=disk[p];return true;}bool Save(string p,JsonSaveContext&j){return j.SaveToFile(p);}bool Exists(string p){return disk.count(p);}} +namespace FileIO{bool CopyFile(string a,string b){if(!disk.count(a))return false;disk[b]=disk[a];return true;}void DeleteFile(string p){disk.erase(p);}} +namespace Replication{bool server=true;bool IsServer(){return server;}} +struct ERR_Settings{static constexpr auto PATH="settings";string m_Path=PATH;bool m_ReadOnly=false;Values m_Values,m_Resolved;int Get(string k){return m_Values.Get(k);} +'''+settings+r''' +}; +namespace ERR_TimeService{bool ParseClock(string,int&){return false;}string Clock(int){return "clock";}} +namespace ERR_EnvironmentService{bool ParseDate(string,int&){return false;}bool ParseProfile(string,int&){return false;}string Date(int){return "date";}string ProfileName(int){return "weather";}} +struct ERR_CommandArguments{ +'''+args+r''' +}; +struct ERR_AdminMenuFields{ +'''+fields+r''' +}; +struct Mode{ERR_Settings* settings;int frozen;ERR_Settings*Settings(){return settings;}int StartMode(){return frozen;}}; +struct Admin{ +'''+command+r''' +}; +int main(){ + assert(ERR_Settings::AddedDefault("startMode")==0); + 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); + 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));} + Mode mode{&settings,0};Admin admin;Array replies; + assert(!admin.StartCommand(&mode,{"mission","radio","show"},replies)); + assert(admin.StartCommand(&mode,{"mission","start","set","crash"},replies)); + assert(settings.Get("startMode")==1&&mode.StartMode()==0&&disk["settings"]["startMode"]==1); + assert(replies.back().find("Next start: Crash | Current round: Prison")!=string::npos); + assert(replies.back().find("Saved for next round")!=string::npos); + ERR_Settings restarted;assert(restarted.Read("settings",restarted.m_Values));Mode next{&restarted,restarted.Get("startMode")};assert(next.StartMode()==1); + auto saved=disk["settings"];failSave=true;admin.StartCommand(&mode,{"mission","start","set","prison"},replies);assert(settings.Get("startMode")==1&&disk["settings"]==saved&&mode.StartMode()==0);failSave=false; + for(string bad:{"random","random-day","2","-1","0.5","crashjunk"}){admin.StartCommand(&mode,{"mission","start","set",bad},replies);assert(settings.Get("startMode")==1&&disk["settings"]==saved);} + admin.StartCommand(&mode,{"mission","start","set","prison","extra"},replies);assert(settings.Get("startMode")==1); + Replication::server=false;admin.StartCommand(&mode,{"mission","start","set","prison"},replies);assert(settings.Get("startMode")==1);Replication::server=true; + settings.m_ReadOnly=true;admin.StartCommand(&mode,{"mission","start","set","prison"},replies);assert(settings.Get("startMode")==1);settings.m_ReadOnly=false; + admin.StartCommand(&mode,{"mission","start","set","0"},replies);assert(settings.Get("startMode")==0&&mode.StartMode()==0); + admin.StartCommand(&next,{"mission","start","show"},replies);assert(replies.back()=="Next start: Crash | Current round: Crash"); +} +''') +assert 'm_Values.Set("startMode", AddedDefault("startMode"));' in S.read_text() +execute=body(A,'void Execute(');assert execute.index('if (!authorized)')