Reorganize mission menu and separate design from logic

This commit is contained in:
2026-10-17 18:27:31 +02:00
parent 0e71ef1fa7
commit d5464f7438
52 changed files with 2596 additions and 716 deletions
+15 -11
View File
@@ -7,8 +7,10 @@ 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(source, 'static array<string> Keys('))
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"}
assert set(keys) <= set(settings) # Every menu field must have a server setting.
@@ -46,9 +48,9 @@ else:
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'))
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(source, signature)): assert n in names
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:
@@ -59,11 +61,11 @@ for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'J
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')
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(source, 'static bool FollowJournal(')
command = body(source, 'static string Command(')
available = body(source, 'static bool Available(').replace('Keys().Contains(key)', 'Known(key)')
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>
@@ -160,7 +162,7 @@ int main(){
}
''')
# All four rows remain inside the visible Mission page with no overlapping controls.
page = body(layout, 'FrameWidgetClass { Name "SettingsPage"')
page = body(layout, 'FrameWidgetClass { "Is Visible" 1 Name "SettingsPage"')
rectangles = []
for line in page.splitlines()[1:]:
if not line.startswith(('TextWidgetClass', 'ButtonWidgetClass')):
@@ -183,8 +185,10 @@ print('PASS: item editor, preset controls and dynamic item-row layout are struct
# 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'''
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};}};
@@ -213,7 +217,7 @@ 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'):
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
+131
View File
@@ -0,0 +1,131 @@
"""Generated UI stays reproducible; design edits cannot silently break includes/navigation."""
import importlib.util
import json
from pathlib import Path
import re
import shutil
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('menu_builder', ROOT / 'tools/build_admin_menu.py')
builder = importlib.util.module_from_spec(spec)
spec.loader.exec_module(builder)
class MenuSourceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.source = Path(self.temp.name) / 'menu'
shutil.copytree(builder.SOURCE, self.source)
def edit_json(self, name, edit):
path = self.source / name
data = json.loads(path.read_text())
edit(data)
path.write_text(json.dumps(data))
def test_committed_outputs_match_sources_and_widget_ids_are_unique(self):
for path, rendered in builder.render().items():
self.assertEqual((ROOT / path).read_text(), rendered, str(path))
self.assertNotIn('{{', rendered)
layout = builder.render()[builder.LAYOUTS / 'AdminMenu.layout']
names = re.findall(r'\bName "([^"]+)"', layout)
self.assertEqual(len(names), len(set(names)))
def test_theme_edit_reaches_pages_rows_and_runtime_colors(self):
def change(theme):
theme['layout']['COLOR_TEXT'] = '1 0.5 0.25 1'
theme['buttons']['POSITIVE'] = '0xFF123456'
self.edit_json('theme.json', change)
rendered = builder.render(self.source)
for name in ('AdminMenu.layout', 'ContentRow.layout', 'ItemRow.layout', 'ItemWeightRow.layout'):
self.assertIn('Color 1 0.5 0.25 1', rendered[builder.LAYOUTS / name])
self.assertNotIn('Color 0.9 0.92 0.94 1', rendered[builder.LAYOUTS / name])
self.assertIn('POSITIVE = 0xFF123456', rendered[builder.PALETTE])
def test_relabel_and_reorder_groups_preserves_stable_page_ids(self):
def change(nav):
nav['groups'].reverse()
nav['pages'][21]['label'] = 'Special forces'
self.edit_json('navigation.json', change)
rendered = builder.render(self.source)
self.assertIn('SARKA = 21', rendered[builder.PAGES])
layout = rendered[builder.LAYOUTS / 'AdminMenu.layout']
self.assertIn('m_sText "Special forces"', layout)
self.assertLess(layout.index('Name "Main_Status"'), layout.index('Name "Main_Mission"'))
def test_top_tabs_and_sidebar_do_not_overlap_content(self):
layout = builder.render()[builder.LAYOUTS / 'AdminMenu.layout']
slots = dict(re.findall(r'Name "([^"]+)" Slot FrameWidgetSlot \{([^}]+)\}', layout))
def rect(name):
slot = slots[name]
return [int(re.search(r'\b' + key + r' (-?\d+)', slot)[1])
for key in ('PositionX', 'PositionY', 'SizeX', 'SizeY')]
nav = json.loads((builder.SOURCE / 'navigation.json').read_text())
pages = {p['key']: p for p in nav['pages']}
panel_width, panel_height = rect('Panel')[2:]
previous_right = 0
for group in nav['groups']:
x, y, width, height = rect('Main_' + group['name'])
self.assertGreaterEqual(x, previous_right)
self.assertLessEqual(x + width, panel_width)
previous_right = x + width
sx, sy, sw, sh = rect('Sidebar_' + group['name'])
previous_bottom = 0
for key in group['pages']:
page = pages[key]
px, py, pw, ph = rect(page['widget'])
bx, by, bw, bh = rect(page['button'])
self.assertLessEqual(y + height, py)
self.assertLessEqual(sx + bx + bw, px)
self.assertGreaterEqual(by, previous_bottom)
self.assertLessEqual(bx + bw, sw)
self.assertLessEqual(by + bh, sh)
self.assertGreaterEqual(sy + by, py)
self.assertLessEqual(sy + by + bh, py + ph)
self.assertLessEqual(px + pw, panel_width)
self.assertLessEqual(py + ph, panel_height)
previous_bottom = by + bh
def test_editor_initial_visibility_matches_mission_page_even_after_reordering(self):
self.edit_json('navigation.json', lambda nav: nav['groups'].reverse())
layout = builder.render(self.source)[builder.LAYOUTS / 'AdminMenu.layout']
visibility = dict((name, int(value)) for value, name in re.findall(
r'WidgetClass \{ "Is Visible" ([01]) Name "([^"]+)"', layout))
nav = json.loads((self.source / 'navigation.json').read_text())
for page in nav['pages']:
self.assertEqual(visibility[page['widget']], int(page['key'] == 'MISSION'))
self.assertEqual(visibility['Selected_' + page['button']], int(page['key'] == 'MISSION'))
for group in nav['groups']:
expected = int('MISSION' in group['pages'])
self.assertEqual(visibility['Sidebar_' + group['name']], expected)
self.assertEqual(visibility['Selected_Main_' + group['name']], expected)
def test_unknown_token_missing_include_cycle_and_escape_fail(self):
shell = self.source / 'shell.layout.in'
for content in ('{{TYPO}}', '{{include:missing.layout.in}}',
'{{include:shell.layout.in}}', '{{include:../outside.layout.in}}'):
with self.subTest(content=content):
shell.write_text(content)
with self.assertRaises(ValueError):
builder.render(self.source)
def test_duplicate_membership_missing_page_and_invalid_theme_fail(self):
original = (self.source / 'navigation.json').read_text()
for change in (lambda n: n['groups'][0]['pages'].append('SARKA'),
lambda n: n['groups'][0]['pages'].pop(),
lambda n: n['pages'][0].update(id=99)):
(self.source / 'navigation.json').write_text(original)
self.edit_json('navigation.json', change)
with self.assertRaises(ValueError):
builder.render(self.source)
(self.source / 'navigation.json').write_text(original)
self.edit_json('theme.json', lambda t: t['layout'].update(COLOR_TEXT='1 1 1 2'))
with self.assertRaises(ValueError):
builder.render(self.source)
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -58,7 +58,7 @@ helper=body(provider,'protected void ConvoyAutoSetting(')
for forbidden in ('StartConvoy','StartScenarioConvoy','Spawn','m_HQ.'):
assert forbidden not in helper
# Do not advertise this command-only setting as an editable field absent from the actual fixed menu.
menu=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c','static array<string> Keys(')
menu=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuFields.c','static array<string> Keys(')
assert 'automaticConvoyEnabled' not in menu
assert body(provider,'override bool Help(').count('replies.Insert(')<=5
print('PASS: actual read migrates old valid settings, preserves explicit disabled state, rejects invalid data; next-round feedback and fixed menu remain honest')
+1 -1
View File
@@ -4,7 +4,7 @@ from policy_harness import ROOT, body, run
p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_SpeechMenu.c'
src = p.read_text()
layout = (ROOT / 'addons/core/UI/layouts/ERR/AdminMenu.layout').read_text()
menu = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c'
menu = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuPages.c'
for name in ('OpenTTS', 'TTSPage', 'TTSEndpoint', 'TTSToken', 'TTSTest', 'TTSDisable', 'TTSPasteEndpoint', 'TTSPasteToken', 'TTSStatus', 'TTSTokenStatus', 'TTSSelectionState', 'RecordingsOn', 'RecordingsOff', 'RecordingsLabel'):
assert f'Name "{name}"' in layout, name
assert '"TTSPage"' in body(menu, 'static array<string> Pages(')
+1 -1
View File
@@ -17,7 +17,7 @@ def cpp(s):
return s
settings='\n'.join(cpp(body(S,sig)) for sig in ['static array<string> 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('])
fields='\n'.join(cpp(body(CORE/'ERR_AdminMenuFields.c',sig)) for sig in ['static string Display(','static string StartSummary('])
command=cpp(body(A,'protected bool StartCommand('))
run(r'''
#include <cassert>