226 lines
14 KiB
Python
226 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'
|
|
fields = source.with_name('ERR_AdminMenuFields.c')
|
|
navigation = source.with_name('ERR_AdminMenuPages.c')
|
|
layout = BASE / 'addons/core/UI/layouts/ERR/AdminMenu.layout'
|
|
keys = re.findall(r'"([A-Za-z]+)"', body(fields, '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", "selfEpinephrine", "planeReconEnabled", "restartOnEmpty", "showAllSites"}
|
|
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','Sidebar_HQ','PlanePage','Selected_OpenPlane'))
|
|
for signature in ('static array<string> Pages(', 'static array<string> Navigation('):
|
|
for n in re.findall(r'"([A-Za-z]+)"', body(navigation, 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 ('droneReconCount','droneCooldownSeconds','sarkaAngerPerCar','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(fields, '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(fields, 'static bool FollowJournal(')
|
|
command = body(fields, 'static string Command(')
|
|
available = body(fields, '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={
|
|
{"droneReconCount","mission drones"},{"droneCooldownSeconds","mission dronecooldown"},{"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:{"droneReconCount","droneCooldownSeconds","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 { "Is Visible" 1 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').replace('ERR_MenuTheme.', 'ERR_MenuTheme::')
|
|
palette=(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_MenuTheme.c').read_text().split('class ERR_MenuTheme',1)[1]
|
|
palette='struct ERR_MenuTheme'+palette.replace('static const int', 'static constexpr uint32_t')+';\n'
|
|
run('#include <cstdint>\n'+palette+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 ('Sidebar_Items','OpenItems','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')
|