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.
222 lines
14 KiB
Python
222 lines
14 KiB
Python
"""Exercise real menu routing/capability bodies and the authored control contract.
|
|
|
|
This does not claim native focus, rendering or multiplayer RPC coverage.
|
|
"""
|
|
import re,os
|
|
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 = BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c'
|
|
layout = BASE / 'addons/core/UI/layouts/ERR/AdminMenu.layout'
|
|
keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array<string> Keys('))
|
|
settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array<string> Keys('))
|
|
assert len(keys) == len(set(keys)) and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "planeReconEnabled", "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.
|
|
def validate_layout_structure(text):
|
|
tokens = re.findall(r'"(?:[^"\\]|\\.)*"|[{}]|[^\s{}"]+', text)
|
|
stack = []
|
|
prefix = []
|
|
for token in tokens:
|
|
if token.endswith('WidgetClass'):
|
|
assert not stack or stack[-1] == 'children', 'Widget outside parent Children block: ' + token
|
|
if token == '{':
|
|
kind = 'property'
|
|
if any(part.endswith('WidgetClass') for part in prefix):
|
|
kind = 'widget'
|
|
elif not prefix:
|
|
kind = 'children'
|
|
stack.append(kind)
|
|
prefix = []
|
|
elif token == '}':
|
|
assert stack, 'Unexpected closing brace'
|
|
stack.pop()
|
|
prefix = []
|
|
else:
|
|
prefix.append(token)
|
|
assert not stack, 'Unclosed layout block'
|
|
validate_layout_structure(layout.read_text())
|
|
try:
|
|
validate_layout_structure(layout.read_text().replace('Name "JournalState"', 'Name "JournalState" {', 1))
|
|
except AssertionError:
|
|
pass
|
|
else:
|
|
raise AssertionError('Malformed child structure must be rejected')
|
|
names = re.findall(r'\bName "([^"]+)"', layout.read_text())
|
|
assert 'RandomDay_startTimeMinutes' in names
|
|
assert 'PlaneOn' in names and 'PlaneOff' in names
|
|
assert all(n in names for n in ('OpenPlane','PlaneBack','PlanePage','Selected_OpenPlane'))
|
|
for signature in ('static array<string> Pages(', 'static array<string> Navigation('):
|
|
for n in re.findall(r'"([A-Za-z]+)"', body(source, signature)): assert n in names
|
|
assert 'Apply_planeReconEnabled' not in names # Saved-state toggle pair, not a numeric edit.
|
|
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 ('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
|
|
for name in re.findall(r'"([A-Za-z]+)"', body(source, 'override void OnMenuOpen(').split('foreach')[0]):
|
|
assert name in names
|
|
journal = body(source, 'static string JournalText(').replace('ERR_JournalText.Menu', 'ERR_JournalText::Menu')
|
|
menu_format = body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_JournalText.c', 'static string Menu(')
|
|
follow = body(source, 'static bool FollowJournal(')
|
|
command = body(source, 'static string Command(')
|
|
available = body(source, 'static bool Available(').replace('Keys().Contains(key)', 'Known(key)')
|
|
known = ' || '.join('key == "' + key + '"' for key in keys)
|
|
run(r'''
|
|
#include <cassert>
|
|
#include <string>
|
|
#include <vector>
|
|
struct string : std::string {
|
|
using std::string::string;
|
|
string(std::string s):std::string(s){}
|
|
bool IsEmpty(){return empty();}
|
|
int Length(){return size();}
|
|
int IndexOf(string s){auto i=find(s);return i==npos?-1:int(i);}
|
|
string Substring(int i,int n){return substr(i,n);}
|
|
void Replace(string a,string b){size_t i=0;while((i=find(a,i))!=npos){replace(i,a.size(),b);i+=b.size();}}
|
|
string Trim(){auto a=find_first_not_of(" \t\r\n");if(a==npos)return "";auto b=find_last_not_of(" \t\r\n");return substr(a,b-a+1);}
|
|
};
|
|
struct ERR_JournalText {
|
|
''' + menu_format + r'''
|
|
};
|
|
struct Fields {
|
|
static bool Known(string key) {return ''' + known + ';}\n' + command + '\n' + available + '\n' + follow + '\n' + journal + r'''
|
|
};
|
|
int main() {
|
|
string raw="#24 t=7s M3 observe guard";
|
|
assert(Fields::JournalText(raw)=="No. 24 t=7s M3 observe guard");
|
|
assert(raw=="#24 t=7s M3 observe guard");
|
|
assert(Fields::JournalText("unprefixed event")=="unprefixed event");
|
|
assert(Fields::JournalText("#25 delivered #24")=="No. 25 delivered No. 24");
|
|
assert(Fields::JournalText("").empty());
|
|
assert(!Fields::FollowJournal(false,0.5,1000,400,false));
|
|
assert(!Fields::FollowJournal(false,0,1000,400,false));
|
|
assert(!Fields::FollowJournal(false,0.97,1000,400,false));
|
|
assert(Fields::FollowJournal(false,1,1000,400,false));
|
|
assert(Fields::FollowJournal(false,0,1000,400,true));
|
|
assert(Fields::FollowJournal(false,0,200,400,false));
|
|
assert(Fields::FollowJournal(true,0,0,400,false));
|
|
std::vector<std::pair<string,string>> cases={
|
|
{"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"},
|
|
{"planeAltitudeMetres","mission planealtitude"},{"planeReplacementSeconds","mission planereplacement"},{"planeSectorSeconds","mission planesector"},{"fieldCampCount","mission camps"},{"fieldHospitalCount","mission hospitals"},{"objectGuardCount","mission objectguards"},{"objectGuardEvery","mission objectevery"},{"objectGuardHQMode","mission objecthq"},{"hunterSpawnDistanceMetres","mission hunterdistance"},{"roadblockCount","mission roadblocks"},{"civilianTrafficCount","mission traffic"},{"heliTimerMinutes","hq heli timer"},{"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"},{"convoyCount","hq convoy count"},{"antiAirChancePercent","hq aa"},{"antiPersonnelMineChancePercent","hq mines"}};
|
|
for(auto pair:cases){
|
|
assert(Fields::Command(pair.first," 42 ")==pair.second+" set 42");
|
|
assert(Fields::Command(pair.first,"Random")==pair.second+" set Random");
|
|
assert(Fields::Command(pair.first,"").empty());
|
|
assert(Fields::Command(pair.first," \t ").empty());
|
|
assert(Fields::Command(pair.first,string(33,'x')).empty());
|
|
assert(Fields::Available(pair.first,true,true));
|
|
}
|
|
assert(Fields::Command("startTimeMinutes","14:25")=="mission time set 14:25");
|
|
assert(Fields::Command("startDateCode","2026-09-19")=="mission date set 2026-09-19");
|
|
assert(Fields::Command("weatherProfile","authored")=="mission weather set authored");
|
|
for(string key:{"heliTimer","prisonDistance","stingerChance","mission abandon","","bad"}){
|
|
assert(Fields::Command(key,"1").empty());assert(!Fields::Available(key,true,true));
|
|
}
|
|
for(string key:{"aiDoctrine","aiShadow","planeAltitudeMetres","planeReplacementSeconds","planeSectorSeconds","objectGuardCount","heliTimerMinutes","reportIntervalSeconds","supportSquadLimit","responseRadiusMetres","convoyCount","antiAirChancePercent","antiPersonnelMineChancePercent"}){
|
|
assert(!Fields::Available(key,false,true));assert(Fields::Available(key,true,false));
|
|
}
|
|
assert(!Fields::Available("aiIntelChancePercent",true,false));
|
|
assert(Fields::Available("aiIntelChancePercent",false,true));
|
|
for(string key:{"intelRevealChancePercent","radioDefenceSeconds","startTimeMinutes","timeMultiplier","startDateCode","weatherProfile"}) assert(Fields::Available(key,false,false));
|
|
}
|
|
''')
|
|
print('PASS: actual menu command routing covers all menu 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(')
|
|
assert 'm_Controller.ERR_AdminSubmit("mission keepweapon set " + retain, true)' in click
|
|
assert click.index('!m_Ready') < click.index('"mission keepweapon set "')
|
|
print('PASS: explicit weapon retention buttons use the same ready/authorized server command path')
|
|
|
|
assert all(name in names for name in ('RestartEmptyOn', 'RestartEmptyOff', 'Saved_restartOnEmpty'))
|
|
assert click.index('!m_Ready') < click.index('"mission restartempty set "')
|
|
toggle = body(source, 'if (name == "RestartEmptyOn" || name == "RestartEmptyOff")')
|
|
toggle = toggle.replace('m_Controller.', 'm_Controller->')
|
|
run(r'''
|
|
#include <cassert>
|
|
#include <string>
|
|
using string=std::string;
|
|
struct Controller {int calls=0;string last;void ERR_AdminSubmit(string command,bool menu){assert(menu);calls++;last=command;}};
|
|
struct Menu {
|
|
int m_RestartEmptyState=-1;float m_ClickDelay=0;Controller controller;Controller* m_Controller=&controller;
|
|
void SetText(string,string){}
|
|
void Click(string name){
|
|
''' + toggle + r'''
|
|
}
|
|
};
|
|
int main(){
|
|
Menu m;m.Click("RestartEmptyOn");assert(m.controller.calls==0);
|
|
m.m_RestartEmptyState=0;m.Click("RestartEmptyOn");
|
|
assert(m.controller.last=="mission restartempty set 1"&&m.controller.calls==1);
|
|
m.m_RestartEmptyState=1;m.Click("RestartEmptyOff");
|
|
assert(m.controller.last=="mission restartempty set 0"&&m.controller.calls==2);
|
|
}
|
|
''')
|
|
# All four rows remain inside the visible Mission page with no overlapping controls.
|
|
page = body(layout, 'FrameWidgetClass { Name "SettingsPage"')
|
|
rectangles = []
|
|
for line in page.splitlines()[1:]:
|
|
if not line.startswith(('TextWidgetClass', 'ButtonWidgetClass')):
|
|
continue
|
|
name = re.search(r'Name "([^"]+)"', line)[1]
|
|
x, y, width, height = (int(re.search(r'\b' + prop + r' (\d+)', line)[1])
|
|
for prop in ('PositionX', 'PositionY', 'SizeX', 'SizeY'))
|
|
assert x + width <= 1000 and y + height <= 486, name
|
|
for other, ox, oy, ow, oh in rectangles:
|
|
assert x + width <= ox or ox + ow <= x or y + height <= oy or oy + oh <= y, (name, other)
|
|
rectangles.append((name, x, y, width, height))
|
|
print('PASS: restart toggle submits authorized settings commands; Mission controls fit without overlap')
|
|
|
|
# The edit page must carry every control bound by the extension, with a valid row.
|
|
items_menu = (ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ServerItemsMenu.c').read_text()
|
|
for name in ('ItemsMod','ItemsShow','ItemsEnableAll','ItemsDisableAll','ItemsPrevious','ItemsNext','ItemsPresets','ItemsPresetLoad','ItemsPresetDelete','ItemsPresetName','ItemsPresetSave','ItemsStorage','ItemsContent'):
|
|
assert name in names, name
|
|
validate_layout_structure((ROOT/'addons/core/UI/layouts/ERR/ItemRow.layout').read_text())
|
|
print('PASS: item editor, preset controls and dynamic item-row layout are structurally valid')
|
|
|
|
# Run the production selected-state styling: retain selected color at disabled
|
|
# opacity 1, while unselected controls remain neutral rather than looking active.
|
|
choice=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SelectionStyle.c','static void Choice(').replace('SCR_ButtonTextComponent button','SCR_ButtonTextComponent* button').replace('button.','button->').replace('Color.FromInt','Color::FromInt')
|
|
run(r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
struct Color{uint32_t value;static Color FromInt(uint32_t v){return {v};}};
|
|
struct SCR_ButtonTextComponent{
|
|
float opacity=0;bool border=false;Color background{0};
|
|
void SetDisabledOpacity(float v){opacity=v;}
|
|
void SetBackgroundColors(Color normal,Color,Color selected,Color,Color){background=normal;assert(normal.value==selected.value);}
|
|
void ColorizeBackground(bool animate){assert(!animate);}
|
|
void ShowBorder(bool selected,bool animate){assert(!animate);border=selected;}
|
|
};
|
|
'''+choice+r'''
|
|
int main(){
|
|
Choice(nullptr,true);
|
|
SCR_ButtonTextComponent button;
|
|
Choice(&button,true,true);assert(button.background.value==0xFF246B3C&&button.opacity==1&&button.border);
|
|
Choice(&button,false,true);assert(button.background.value==0xFF454B52&&!button.border);
|
|
Choice(&button,true,false);assert(button.background.value==0xFF8F2929&&button.opacity==1&&button.border);
|
|
Choice(&button,false,false);assert(button.background.value==0xFF454B52&&!button.border);
|
|
}
|
|
''')
|
|
print('PASS: selected choices remain green/red and fully visible when disabled; unselected choices return to neutral')
|
|
|
|
# Probability controls share the authenticated item editor and use a distinct row.
|
|
weight_menu=(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ServerItemsWeightsMenu.c').read_text()
|
|
weight_layout=(ROOT/'addons/core/UI/layouts/ERR/ItemWeightRow.layout').read_text()
|
|
validate_layout_structure(weight_layout)
|
|
weight_names=re.findall(r'\bName "([^"]+)"', weight_layout)
|
|
assert len(weight_names)==len(set(weight_names))
|
|
for name in ('EquipmentNavigation','EquipmentOverview','OpenAllowedItems','AllowedItemsPage','OpenItemPresets','ItemPresetsPage','OpenWeights','WeightsPage','WeightsMod','WeightsShow','WeightsPrevious','WeightsNext','ModWeight','ModWeightApply','ModWeightNormal','ModWeightHigh','ModWeightSaved','WeightsChance','WeightsPageState','WeightsContent','WeightsScroll'):
|
|
assert name in names, name
|
|
for name in ('WeightLabel','WeightState','WeightValue','WeightApply','WeightNormal'):
|
|
assert name in weight_names, name
|
|
assert 'SubmitItems("items weight "' in weight_menu
|
|
print('PASS: probability editor and weighted item-row layout have all bound controls')
|