Operator console app: PySide6 station + validated egg model (the lost 1995 console, recreated)

The operator station the archive never preserved: build missions with
authoritative dropdowns, run the console/relay, watch pods arrive, launch --
LAN mesh or internet relay -- plus one-click local instances for testing.

tools/eggmodel.py (headless, importable, self-tested 14/14):
- Order-preserving egg parse/emit: the editor manages [mission]/[pilots]/
  per-pilot pages; bitmap rasters, ordinals and role pages survive untouched.
- EVERY value set read LIVE from content/BTL4.RES (the resscan parser,
  importable): maps = MakeMessageStream(14) INTERSECT ExistanceBoxStream(26)
  (the game aborts unless both exist); colors/badges/patches from the
  VehicleTable resource (type 25, NUL-line NotationFile text with ;-comments);
  vehicles = ModelList names, known-good mechs (bhk1, madcat) surfaced first.
- validate(): the full required-key ruleset from MISSION.cpp/btl4mssn.cpp
  (missing map/time/weather/scenario, per-pilot hostType/vehicle/dropzone/
  color/patch/badge/experience/role, role page model=, illegal values,
  duplicate addresses) -- kills the hand-edited-egg crash class, incl. the
  color=Red-vs-Crimson gotcha that bit the dev eggs.

tools/btoperator.py (PySide6, dark Fusion theme):
- Mission form + pilot roster table (all dropdowns from eggmodel), relay-tag
  auto-numbering, add/remove pilots, egg New/Open/Save/Validate.
- Mode switch: Relay (internet; runs btconsole --relay) / Mesh (LAN legacy;
  dial-out console at address-port minus 1 per the +1 rule).
- Session panel: QProcess drives btconsole.py; stdout parsed live into
  per-pilot state lights (waiting/egg/registered/LAUNCHED) + relay stats.
- Local launcher: spawns btl4.exe per roster row with BT_RELAY/BT_SELF (or
  mesh -net port), BT_DEV_GAUGES/BT_START_INSIDE toggles.
- Export player scripts: per-pilot join_as_playerN.bat with the public
  relay hostname (a remote player's whole setup = run one .bat).
- Wire/protocol code stays in btconsole.py/eggmodel.py -- the GUI is only
  UI + process management; CLI + test harnesses share the same tested core.

Verified: eggmodel self-test 14/14; scripted end-to-end
(scratchpad/operator_e2e.py) drives the real window through its own methods --
start relay session -> launch 2 local instances -> monitor observes the
mission LAUNCH (user-confirmed live on screen: both pods in-mission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-18 10:57:30 -05:00
co-authored by Claude Fable 5
parent dbb9af2dfa
commit 43fa53d542
4 changed files with 1403 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
[mission]
adventure=BattleTech
map=cavern
scenario=freeforall
time=night
weather=clear
temperature=27
length=600
[ordinals]
bitmap=Ordinal::BitMap::1
bitmap=Ordinal::BitMap::2
bitmap=Ordinal::BitMap::3
bitmap=Ordinal::BitMap::4
[Ordinal::BitMap::1]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000038000003C000001FF00000000F00
bitmap=0000F8000003C000003FF80000000F00
bitmap=0001F8000003C00000707C0000000F00
bitmap=0001F8000003C00000603C0000000F00
bitmap=00007801FC0FF00000003C3DF807FF00
bitmap=00007803FE0FF00000003C3FFC0FFF00
bitmap=00007803C703C00000003C3E3C1F0F00
bitmap=000078078303C0000000783C1E1E0F00
bitmap=000078078003C0000000783C1E1E0F00
bitmap=00007807C003C0000000F03C1E1E0F00
bitmap=00007807F003C0000001E03C1E1E0F00
bitmap=00007803FC03C0000003C03C1E1E0F00
bitmap=00007801FE03C0000007803C1E1E0F00
bitmap=000078007F03C000000F003C1E1E0F00
bitmap=000078001F03C000001E003C1E1E0F00
bitmap=000078000F03C000003C003C1E1E0F00
bitmap=000078060F03C0000078003C1E1E0F00
bitmap=000078071E03E0000078003C1E1F1F00
bitmap=00007803FE01F000007FFC3C1E0FFF00
bitmap=00007801FC00F000007FFC3C1E07EF00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::2]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF0000003C0000003C03C0780000
bitmap=001FFF0000003C0000007C03C0780000
bitmap=00000E0000003C000000FC03C0780000
bitmap=00003C0000003C000001BC03C0780000
bitmap=0000700F3E1FFC000003BC0FF07BF000
bitmap=0001E00F7E3FFC0000073C0FF07FF800
bitmap=0003800FFE7C3C0000063C03C07C7800
bitmap=0007F80FFE783C00000C3C03C0783C00
bitmap=0007FE0F80783C0000183C03C0783C00
bitmap=00001E0F00783C0000383C03C0783C00
bitmap=00000F0F00783C0000703C03C0783C00
bitmap=00000F0F00783C00007FFF03C0783C00
bitmap=00000F0F00783C00007FFF03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00000F0F00783C0000003C03C0783C00
bitmap=00180F0F00783C0000003C03C0783C00
bitmap=001C1F0F007C7C0000003C03E0783C00
bitmap=000FFE0F003FFC0000003C01F0783C00
bitmap=0007FC0F001FBC0000003C00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::3]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF03C07800000000FC03C0780000
bitmap=001FFF03C07800000003FC03C0780000
bitmap=001E0003C07800000007C003C0780000
bitmap=001E0003C0780000000F0003C0780000
bitmap=001E000FF07BF000000F000FF07BF000
bitmap=001E000FF07FF800001E000FF07FF800
bitmap=001E0003C07C7800001E0003C07C7800
bitmap=001FFC03C0783C00001EFC03C0783C00
bitmap=001FFE03C0783C00001FFE03C0783C00
bitmap=00001F03C0783C00001F1F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00000F03C0783C00001E0F03C0783C00
bitmap=00180F03C0783C00001E0F03C0783C00
bitmap=001C1F03E0783C00001F1F03E0783C00
bitmap=000FFE01F0783C00000FFE01F0783C00
bitmap=0007FC00F0783C000007FC00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[Ordinal::BitMap::4]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=001FFF03C07800000007FC03C0780000
bitmap=001FFF03C0780000000FFE03C0780000
bitmap=00000F03C0780000001F1F03C0780000
bitmap=00000F03C0780000001E0F03C0780000
bitmap=00000F0FF07BF000001E0F0FF07BF000
bitmap=00001F0FF07FF800001E0F0FF07FF800
bitmap=00001E03C07C7800001E0F03C07C7800
bitmap=00003E03C0783C00001E0F03C0783C00
bitmap=00003C03C0783C00000F1E03C0783C00
bitmap=00003C03C0783C000007FC03C0783C00
bitmap=00007803C0783C000007FC03C0783C00
bitmap=00007803C0783C00000F1E03C0783C00
bitmap=00007803C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003C0783C00001E0F03C0783C00
bitmap=0000F003E0783C00000F1E03E0783C00
bitmap=0000F001F0783C00000FFE01F0783C00
bitmap=0000F000F0783C000007FC00F0783C00
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[pilots]
pilot=10.99.0.1:1502
pilot=10.99.0.2:1502
[10.99.0.1:1502]
hostType=0
advancedDamage=1
loadzones=1
name=Aeolus
bitmapindex=1
experience=expert
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=bhk1
vehicleValue=1000
color=White
[10.99.0.2:1502]
hostType=0
advancedDamage=1
loadzones=1
name=Aeolus
bitmapindex=2
experience=expert
badge=VGL
patch=Yellow
role=Role::Default
dropzone=one
vehicle=madcat
vehicleValue=1000
color=Crimson
[largebitmap]
bitmap=BitMap::Large::Aeolus
[BitMap::Large::Aeolus]
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=000003FE0000000001E0000000000000
bitmap=000007FF0000000001E0000000000000
bitmap=00000F8F8000000001E0000000000000
bitmap=00000F078000000001E0000000000000
bitmap=00000F0781FF00FF81E0F0781FC00000
bitmap=00000F0783FF81FFC1E0F0783FE00000
bitmap=00000F0787C7C3E3E1E0F0783C700000
bitmap=00000F078783C3C1E1E0F07878300000
bitmap=00000F078783C3C1E1E0F07878000000
bitmap=00000FFF8783C3C1E1E0F0787C000000
bitmap=00000FFF8783C3C1E1E0F0787F000000
bitmap=00000F0787FFC3C1E1E0F0783FC00000
bitmap=00000F0787FFC3C1E1E0F0781FE00000
bitmap=00000F07878003C1E1E0F07807F00000
bitmap=00000F07878003C1E1E0F07801F00000
bitmap=00000F07878003C1E1E0F07800F00000
bitmap=00000F078780C3C1E1E0F07860F00000
bitmap=00000F0787C1C3E3E1E078F871E00000
bitmap=00000F0783FF81FFC1E07FF83FE00000
bitmap=00000F0781FF00FF81E03F781FC00000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
bitmap=00000000000000000000000000000000
x=128
y=32
width=8
[smallbitmap]
bitmap=BitMap::Small::Aeolus
[BitMap::Small::Aeolus]
bitmap=00000000000000000000000000000000
bitmap=0000000000000000003E00000C000000
bitmap=006300000C00000000631F0F8CC63C00
bitmap=00633198CCC66200007F3198CCC66000
bitmap=00633F98CCC6780000633018CCC61E00
bitmap=00633018CCC6060000633098CCC64600
bitmap=00631F0F8C7E3C000000000000000000
bitmap=00000000000000000000000000000000
x=64
y=16
width=4
[Role::Default]
model=dfltrole
[Role::NoReturn]
model=noretun
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python
"""operator_e2e.py -- scripted end-to-end test of the operator console.
Drives the real Operator window through its own methods on a timer:
start a RELAY session -> launch 2 local btl4 instances -> wait for the
mission LAUNCH to be observed by the session monitor -> report + shut down.
Exercises the exact QProcess paths the buttons use (no synthetic clicks).
Exit 0 = launched; 1 = timed out.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "tools"))
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox
import btoperator
# never block on dialogs in scripted mode
QMessageBox.warning = staticmethod(lambda *a, **k: print("[e2e] WARN dialog:", a[2] if len(a) > 2 else a))
QMessageBox.information = staticmethod(lambda *a, **k: None)
TIMEOUT_S = 120
result = {"ok": False}
def main():
app = QApplication(sys.argv)
btoperator.dark_palette(app)
win = btoperator.Operator()
win.show()
def step_start():
print("[e2e] starting relay session")
win._start_session()
assert win.console_proc is not None, "session did not start"
QTimer.singleShot(3000, step_launch)
def step_launch():
print("[e2e] launching local instances")
win._launch_local()
assert len(win.game_procs) == 2, \
"expected 2 local instances, got %d" % len(win.game_procs)
poll.start(2000)
ticks = {"n": 0}
def step_poll():
ticks["n"] += 1
m = win.monitor
states = dict(m.state) if m else {}
print("[e2e] t=%ds states=%s launched=%s" %
(ticks["n"] * 2, states, m.launched if m else "?"))
if m and m.launched:
result["ok"] = True
status = win.pod_status.text().encode("ascii", "replace").decode()
print("[e2e] LAUNCH observed -- pod status: %s" % status)
finish()
elif ticks["n"] * 2 >= TIMEOUT_S:
print("[e2e] TIMEOUT")
finish()
def finish():
poll.stop()
win._stop_session()
QTimer.singleShot(500, app.quit)
poll = QTimer()
poll.timeout.connect(step_poll)
QTimer.singleShot(1000, step_start)
app.exec()
print("[e2e] %s" % ("PASS" if result["ok"] else "FAIL"))
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
+679
View File
@@ -0,0 +1,679 @@
#!/usr/bin/env python
"""btoperator.py -- BT411 OPERATOR CONSOLE (PySide6).
The system-operator station the 1995 archive lost, recreated: build missions
with validated dropdowns (every value read live from BTL4.RES via eggmodel),
run the console/relay, watch pods arrive, and launch games -- LAN mesh or
internet relay -- plus one-click local instances for testing.
python tools/btoperator.py
Modes:
Relay (internet) -- runs `btconsole.py --relay`; pods dial OUT to this box
(players: BT_RELAY=<host>:<port> + BT_SELF=<tag>).
Mesh (LAN legacy) -- runs the classic dial-out console against each pod's
-net port (authentic cabinet topology).
The egg/protocol logic stays headless in eggmodel.py / btconsole.py; this file
is only UI + process management (QProcess), so the tested wire code is shared
with the CLI and the test harnesses.
"""
import os
import re
import sys
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
from PySide6.QtGui import QColor, QFont, QPalette, QAction
from PySide6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPlainTextEdit, QPushButton, QSpinBox, QSplitter, QTableWidget,
QTableWidgetItem, QVBoxLayout, QWidget, QStatusBar)
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, ".."))
CONTENT = os.path.join(REPO, "content")
DEFAULT_EXE = os.path.join(REPO, "build", "Release", "btl4.exe")
DEFAULT_EGG = os.path.join(CONTENT, "OPERATOR.EGG")
BTCONSOLE = os.path.join(HERE, "btconsole.py")
sys.path.insert(0, HERE)
import eggmodel # noqa: E402
# ---------------------------------------------------------------------------
# Pilot state model (parsed live from console/relay stdout)
# ---------------------------------------------------------------------------
ST_IDLE, ST_EGG, ST_REGISTERED, ST_LAUNCHED = (
"waiting", "egg sent", "registered", "LAUNCHED")
ST_COLORS = {ST_IDLE: "#666666", ST_EGG: "#b58900",
ST_REGISTERED: "#2aa198", ST_LAUNCHED: "#33cc55"}
class SessionMonitor:
"""Turns btconsole/relay stdout lines into per-pilot state + stats."""
RE_RELAY_EGG = re.compile(r"console conn .*egg sent .*?(\d+)/(\d+) pods")
RE_RELAY_REG = re.compile(r"game\[.* host=(\d+)\] REGISTERED")
RE_RELAY_RUN = re.compile(r"RunMission #(\d+) sent")
RE_RELAY_STAT = re.compile(r"\[relay-stats\] (.*)")
RE_RELAY_DOWN = re.compile(r"game\[.* host=(\d+)\] dropped")
RE_MESH_EGG = re.compile(r"\[([^\]]+)\] egg sent")
RE_MESH_RUN = re.compile(r"\[([^\]]+)\] RunMission #(\d+) sent")
def __init__(self, relay_mode, tags):
self.relay_mode = relay_mode
self.tags = tags # roster order == hostID order
self.state = {t: ST_IDLE for t in tags}
self.eggs = 0
self.launched = False
self.stats = ""
def host_tag(self, host_id):
i = host_id - 2 # hostIDs 2..N+1 in egg order
return self.tags[i] if 0 <= i < len(self.tags) else None
def feed(self, line):
"""Consume one log line; return True if any state changed."""
changed = False
if self.relay_mode:
m = self.RE_RELAY_EGG.search(line)
if m:
self.eggs = int(m.group(1))
changed = True
m = self.RE_RELAY_REG.search(line)
if m:
tag = self.host_tag(int(m.group(1)))
if tag:
self.state[tag] = ST_REGISTERED
changed = True
m = self.RE_RELAY_DOWN.search(line)
if m:
tag = self.host_tag(int(m.group(1)))
if tag and self.state.get(tag) != ST_IDLE:
self.state[tag] = ST_IDLE
changed = True
m = self.RE_RELAY_RUN.search(line)
if m and int(m.group(1)) >= 2:
self.launched = True
for t in self.state:
if self.state[t] == ST_REGISTERED:
self.state[t] = ST_LAUNCHED
changed = True
m = self.RE_RELAY_STAT.search(line)
if m:
self.stats = m.group(1)
changed = True
else:
m = self.RE_MESH_EGG.search(line)
if m:
# mesh log key = host:consolePort; roster tag = host:gamePort
tag = self._mesh_tag(m.group(1))
if tag:
self.state[tag] = ST_EGG
changed = True
m = self.RE_MESH_RUN.search(line)
if m and int(m.group(2)) >= 2:
tag = self._mesh_tag(m.group(1))
if tag:
self.state[tag] = ST_LAUNCHED
self.launched = True
changed = True
return changed
def _mesh_tag(self, console_hostport):
host, _, port = console_hostport.rpartition(":")
try:
game = "%s:%d" % (host, int(port) + 1)
except ValueError:
return None
return game if game in self.state else None
# ---------------------------------------------------------------------------
# Main window
# ---------------------------------------------------------------------------
class Operator(QMainWindow):
COLS = ["Identity / Address", "Vehicle", "Color", "Badge", "Patch",
"Experience", "Local"]
def __init__(self):
super().__init__()
self.setWindowTitle("BT411 Operator Console")
self.resize(1180, 760)
self.values = eggmodel.ValueSets()
self.egg = eggmodel.new_from_template()
self.egg_path = DEFAULT_EGG
self.console_proc = None
self.game_procs = []
self.monitor = None
self._build_ui()
self._load_egg_into_ui()
# ------------------------------------------------------------------ UI --
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
# -- top bar: mode + egg file actions
top = QHBoxLayout()
top.addWidget(QLabel("Mode:"))
self.mode = QComboBox()
self.mode.addItems(["Relay (internet)", "Mesh (LAN legacy)"])
self.mode.currentIndexChanged.connect(self._mode_changed)
top.addWidget(self.mode)
top.addSpacing(24)
for label, slot in (("New", self._egg_new), ("Open…", self._egg_open),
("Save", self._egg_save),
("Save As…", self._egg_save_as)):
b = QPushButton(label)
b.clicked.connect(slot)
top.addWidget(b)
self.egg_label = QLabel(os.path.basename(self.egg_path))
self.egg_label.setStyleSheet("color:#888;")
top.addWidget(self.egg_label)
top.addStretch(1)
self.validate_btn = QPushButton("Validate")
self.validate_btn.clicked.connect(self._validate_clicked)
top.addWidget(self.validate_btn)
root.addLayout(top)
split = QSplitter(Qt.Vertical)
root.addWidget(split, 1)
upper = QWidget()
upper_l = QHBoxLayout(upper)
upper_l.setContentsMargins(0, 0, 0, 0)
split.addWidget(upper)
# -- mission + network forms
left = QVBoxLayout()
upper_l.addLayout(left, 0)
mission_box = QGroupBox("Mission")
form = QFormLayout(mission_box)
self.f_map = QComboBox(); self.f_map.addItems(self.values.maps)
self.f_time = QComboBox(); self.f_time.addItems(self.values.times)
self.f_weather = QComboBox(); self.f_weather.addItems(self.values.weathers)
self.f_scenario = QComboBox(); self.f_scenario.addItems(self.values.scenarios)
self.f_temp = QLineEdit(); self.f_temp.setPlaceholderText("e.g. 25.0")
self.f_length = QLineEdit(); self.f_length.setPlaceholderText("seconds; blank = unlimited")
form.addRow("Map", self.f_map)
form.addRow("Time", self.f_time)
form.addRow("Weather", self.f_weather)
form.addRow("Scenario", self.f_scenario)
form.addRow("Temperature", self.f_temp)
form.addRow("Game length", self.f_length)
left.addWidget(mission_box)
net_box = QGroupBox("Network")
nform = QFormLayout(net_box)
self.f_port = QSpinBox()
self.f_port.setRange(1024, 65000)
self.f_port.setValue(1500)
self.f_public = QLineEdit()
self.f_public.setPlaceholderText("public hostname for player scripts")
nform.addRow("Console port", self.f_port)
nform.addRow("Public host", self.f_public)
left.addWidget(net_box)
exe_box = QGroupBox("Local game")
eform = QFormLayout(exe_box)
self.f_exe = QLineEdit(DEFAULT_EXE)
pick = QPushButton("")
pick.setFixedWidth(28)
pick.clicked.connect(self._pick_exe)
exe_row = QHBoxLayout()
exe_row.addWidget(self.f_exe, 1)
exe_row.addWidget(pick)
eform.addRow("btl4.exe", exe_row)
self.f_gauges = QCheckBox("Dev gauges"); self.f_gauges.setChecked(True)
self.f_inside = QCheckBox("Start in cockpit"); self.f_inside.setChecked(True)
opt_row = QHBoxLayout()
opt_row.addWidget(self.f_gauges)
opt_row.addWidget(self.f_inside)
eform.addRow(opt_row)
left.addWidget(exe_box)
left.addStretch(1)
# -- roster
roster_box = QGroupBox("Pilot roster (hostIDs assigned in this order)")
rl = QVBoxLayout(roster_box)
self.table = QTableWidget(0, len(self.COLS))
self.table.setHorizontalHeaderLabels(self.COLS)
self.table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.Stretch)
self.table.verticalHeader().setVisible(True)
rl.addWidget(self.table, 1)
btns = QHBoxLayout()
add = QPushButton("Add pilot"); add.clicked.connect(self._add_pilot)
rem = QPushButton("Remove selected"); rem.clicked.connect(self._remove_pilot)
self.export_btn = QPushButton("Export player scripts…")
self.export_btn.clicked.connect(self._export_scripts)
btns.addWidget(add); btns.addWidget(rem)
btns.addStretch(1); btns.addWidget(self.export_btn)
rl.addLayout(btns)
upper_l.addWidget(roster_box, 1)
# -- session panel
lower = QWidget()
low = QVBoxLayout(lower)
low.setContentsMargins(0, 0, 0, 0)
split.addWidget(lower)
srow = QHBoxLayout()
self.start_btn = QPushButton("▶ Start session")
self.start_btn.setStyleSheet("font-weight:bold;")
self.start_btn.clicked.connect(self._start_session)
self.stop_btn = QPushButton("■ Stop session")
self.stop_btn.clicked.connect(self._stop_session)
self.stop_btn.setEnabled(False)
self.launch_local_btn = QPushButton("Launch local instances")
self.launch_local_btn.clicked.connect(self._launch_local)
self.launch_local_btn.setEnabled(False)
self.stop_games_btn = QPushButton("Stop games")
self.stop_games_btn.clicked.connect(self._stop_games)
srow.addWidget(self.start_btn)
srow.addWidget(self.stop_btn)
srow.addSpacing(20)
srow.addWidget(self.launch_local_btn)
srow.addWidget(self.stop_games_btn)
srow.addStretch(1)
self.stats_label = QLabel("")
self.stats_label.setStyleSheet("color:#7a9;")
srow.addWidget(self.stats_label)
low.addLayout(srow)
self.pod_status = QLabel("session not running")
self.pod_status.setTextFormat(Qt.RichText)
low.addWidget(self.pod_status)
self.log = QPlainTextEdit()
self.log.setReadOnly(True)
self.log.setMaximumBlockCount(2000)
self.log.setFont(QFont("Consolas", 9))
low.addWidget(self.log, 1)
split.setSizes([440, 300])
self.setStatusBar(QStatusBar())
self._status("ready — values loaded live from BTL4.RES: "
f"{len(self.values.maps)} maps, "
f"{len(self.values.vehicles)} vehicles, "
f"{len(self.values.colors)} colors")
def _status(self, text):
self.statusBar().showMessage(text)
# -------------------------------------------------------------- roster --
def _combo(self, items, current):
c = QComboBox()
c.addItems(items)
if current in items:
c.setCurrentText(current)
elif current:
c.insertItem(0, current)
c.setCurrentIndex(0)
return c
def _add_pilot_row(self, pilot, local=False):
r = self.table.rowCount()
self.table.insertRow(r)
addr = QTableWidgetItem(pilot.get("address", ""))
if self.mode.currentIndex() == 0: # relay tags are auto-managed
addr.setFlags(addr.flags() & ~Qt.ItemIsEditable)
self.table.setItem(r, 0, addr)
self.table.setCellWidget(r, 1, self._combo(
self.values.vehicles, pilot.get("vehicle") or "bhk1"))
self.table.setCellWidget(r, 2, self._combo(
self.values.colors, pilot.get("color") or "White"))
self.table.setCellWidget(r, 3, self._combo(
self.values.badges, pilot.get("badge") or "VGL"))
self.table.setCellWidget(r, 4, self._combo(
self.values.patches, pilot.get("patch") or "Yellow"))
self.table.setCellWidget(r, 5, self._combo(
self.values.experiences, pilot.get("experience") or "expert"))
chk = QCheckBox()
chk.setChecked(local)
wrap = QWidget()
wl = QHBoxLayout(wrap)
wl.setContentsMargins(0, 0, 0, 0)
wl.setAlignment(Qt.AlignCenter)
wl.addWidget(chk)
self.table.setCellWidget(r, 6, wrap)
def _row_pilot(self, r, template):
"""Read one roster row back into a pilot dict."""
p = dict(template)
p["address"] = self.table.item(r, 0).text().strip()
p["vehicle"] = self.table.cellWidget(r, 1).currentText()
p["color"] = self.table.cellWidget(r, 2).currentText()
p["badge"] = self.table.cellWidget(r, 3).currentText()
p["patch"] = self.table.cellWidget(r, 4).currentText()
p["experience"] = self.table.cellWidget(r, 5).currentText()
p["bitmapindex"] = str(min(r + 1, 8))
return p
def _row_local(self, r):
wrap = self.table.cellWidget(r, 6)
return wrap.findChild(QCheckBox).isChecked() if wrap else False
def _pilot_template(self):
pilots = self.egg.pilots()
if pilots:
t = dict(pilots[0])
else:
t = {"hostType": "0", "advancedDamage": "1", "loadzones": "1",
"name": "Pilot", "experience": "expert", "badge": "VGL",
"patch": "Yellow", "role": "Role::Default",
"dropzone": "one", "vehicle": "bhk1", "vehicleValue": "1000",
"color": "White"}
t.pop("address", None)
return t
def _add_pilot(self):
n = self.table.rowCount() + 1
if self.mode.currentIndex() == 0:
address = eggmodel.relay_tags(n)[-1]
else:
address = "192.168.1.%d:1502" % (99 + n)
p = self._pilot_template()
p["address"] = address
self._add_pilot_row(p, local=(self.mode.currentIndex() == 0))
self._renumber_relay_tags()
def _remove_pilot(self):
r = self.table.currentRow()
if r >= 0:
self.table.removeRow(r)
self._renumber_relay_tags()
def _renumber_relay_tags(self):
if self.mode.currentIndex() != 0:
return
tags = eggmodel.relay_tags(self.table.rowCount())
for r, tag in enumerate(tags):
self.table.item(r, 0).setText(tag)
def _mode_changed(self, _index):
relay = self.mode.currentIndex() == 0
for r in range(self.table.rowCount()):
item = self.table.item(r, 0)
flags = item.flags()
item.setFlags((flags & ~Qt.ItemIsEditable) if relay
else (flags | Qt.ItemIsEditable))
if relay:
self._renumber_relay_tags()
self._status("mode: %s" % self.mode.currentText())
# ------------------------------------------------------------ egg file --
def _load_egg_into_ui(self):
m = self.egg.mission()
for combo, key in ((self.f_map, "map"), (self.f_time, "time"),
(self.f_weather, "weather"),
(self.f_scenario, "scenario")):
if m.get(key):
combo.setCurrentText(m[key])
self.f_temp.setText(m.get("temperature") or "")
self.f_length.setText(m.get("length") or "")
self.table.setRowCount(0)
relay = self.mode.currentIndex() == 0
for p in self.egg.pilots():
self._add_pilot_row(p, local=relay)
self._renumber_relay_tags()
self.egg_label.setText(os.path.basename(self.egg_path))
def _collect_egg(self):
"""UI -> self.egg (returns validation problems)."""
self.egg.set_mission(
map=self.f_map.currentText(), time=self.f_time.currentText(),
weather=self.f_weather.currentText(),
scenario=self.f_scenario.currentText(),
temperature=self.f_temp.text().strip(),
length=self.f_length.text().strip())
template = self._pilot_template()
pilots = [self._row_pilot(r, template)
for r in range(self.table.rowCount())]
self.egg.set_pilots(pilots)
return self.egg.validate(self.values)
def _egg_new(self):
self.egg = eggmodel.new_from_template()
self.egg_path = DEFAULT_EGG
self._load_egg_into_ui()
self._status("new mission from template")
def _egg_open(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open mission egg", CONTENT, "Egg files (*.EGG *.egg)")
if path:
self.egg = eggmodel.EggDoc.load(path)
self.egg_path = path
self._load_egg_into_ui()
self._status("opened %s" % path)
def _egg_save(self):
problems = self._collect_egg()
if problems and not self._confirm_problems(problems, "Save anyway?"):
return False
self.egg.save(self.egg_path)
self.egg_label.setText(os.path.basename(self.egg_path))
self._status("saved %s" % self.egg_path)
return True
def _egg_save_as(self):
path, _ = QFileDialog.getSaveFileName(
self, "Save mission egg", self.egg_path, "Egg files (*.EGG *.egg)")
if path:
self.egg_path = path
self._egg_save()
def _validate_clicked(self):
problems = self._collect_egg()
if problems:
QMessageBox.warning(self, "Validation",
"\n".join("%s" % p for p in problems))
else:
QMessageBox.information(self, "Validation",
"Mission is valid and launchable.")
def _confirm_problems(self, problems, question):
return QMessageBox.question(
self, "Validation problems",
"\n".join("%s" % p for p in problems) + "\n\n" + question,
QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes
# ------------------------------------------------------------- session --
def _start_session(self):
problems = self._collect_egg()
if problems:
QMessageBox.warning(self, "Cannot start", "\n".join(problems))
return
self.egg.save(self.egg_path)
relay = self.mode.currentIndex() == 0
tags = [p["address"] for p in self.egg.pilots()]
self.monitor = SessionMonitor(relay, tags)
args = [BTCONSOLE]
if relay:
args += ["--relay", str(self.f_port.value()), self.egg_path]
else:
# mesh: console dials each pod's CONSOLE port (= game port - 1)
args += [self.egg_path]
for tag in tags:
host, _, port = tag.rpartition(":")
args.append("%s:%d" % (host, int(port) - 1))
self.console_proc = QProcess(self)
self.console_proc.setProcessChannelMode(QProcess.MergedChannels)
self.console_proc.readyReadStandardOutput.connect(self._console_output)
self.console_proc.finished.connect(self._console_finished)
self.console_proc.start(sys.executable, ["-u"] + args)
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.launch_local_btn.setEnabled(True)
self.log.appendPlainText("== session started (%s) ==" %
("relay" if relay else "mesh"))
self._refresh_pod_status()
def _stop_session(self):
if self.console_proc:
self.console_proc.kill()
self.console_proc = None
self._stop_games()
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.launch_local_btn.setEnabled(False)
self.pod_status.setText("session not running")
self.log.appendPlainText("== session stopped ==")
def _console_finished(self):
self.log.appendPlainText("== console/relay process exited ==")
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
def _console_output(self):
if not self.console_proc:
return
data = bytes(self.console_proc.readAllStandardOutput()).decode(
"utf-8", "replace")
for line in data.splitlines():
if line.strip():
self.log.appendPlainText(line)
if self.monitor and self.monitor.feed(line):
self._refresh_pod_status()
def _refresh_pod_status(self):
if not self.monitor:
return
parts = []
for tag, state in self.monitor.state.items():
parts.append('<span style="color:%s">⬤</span> %s <i>%s</i>'
% (ST_COLORS[state], tag, state))
head = "LAUNCHED — mission running" if self.monitor.launched else (
"eggs delivered: %d/%d" % (self.monitor.eggs,
len(self.monitor.tags))
if self.monitor.relay_mode else "mesh console driving pods")
self.pod_status.setText("<b>%s</b> &nbsp;&nbsp; %s"
% (head, " &nbsp; ".join(parts)))
if self.monitor.stats:
self.stats_label.setText(self.monitor.stats)
# -------------------------------------------------------- local launch --
def _pick_exe(self):
path, _ = QFileDialog.getOpenFileName(
self, "Locate btl4.exe", os.path.dirname(self.f_exe.text()),
"Executable (*.exe)")
if path:
self.f_exe.setText(path)
def _launch_local(self):
exe = self.f_exe.text()
if not os.path.isfile(exe):
QMessageBox.warning(self, "Launch", "btl4.exe not found:\n" + exe)
return
relay = self.mode.currentIndex() == 0
pilots = self.egg.pilots()
launched = 0
for r in range(self.table.rowCount()):
if not self._row_local(r) or r >= len(pilots):
continue
tag = pilots[r]["address"]
env = QProcessEnvironment.systemEnvironment()
if self.f_gauges.isChecked():
env.insert("BT_DEV_GAUGES", "1")
if self.f_inside.isChecked():
env.insert("BT_START_INSIDE", "1")
if relay:
env.insert("BT_RELAY", "127.0.0.1:%d" % self.f_port.value())
env.insert("BT_SELF", tag)
net_port = 1501
else:
host, _, port = tag.rpartition(":")
net_port = int(port) - 1
env.insert("BT_LOG", "operator_%d.log" % (r + 1))
proc = QProcess(self)
proc.setWorkingDirectory(CONTENT)
proc.setProcessEnvironment(env)
proc.start(exe, ["-egg", os.path.basename(self.egg_path)
if os.path.dirname(self.egg_path) == CONTENT
else self.egg_path,
"-net", str(net_port)])
self.game_procs.append(proc)
launched += 1
self.log.appendPlainText("launched %d local instance(s)" % launched)
def _stop_games(self):
for proc in self.game_procs:
if proc.state() != QProcess.NotRunning:
proc.kill()
self.game_procs = []
# ------------------------------------------------------ player scripts --
def _export_scripts(self):
problems = self._collect_egg()
if problems and not self._confirm_problems(problems, "Export anyway?"):
return
host = self.f_public.text().strip() or "YOUR.RELAY.HOST"
out_dir = QFileDialog.getExistingDirectory(
self, "Export player launch scripts to…", REPO)
if not out_dir:
return
for i, p in enumerate(self.egg.pilots()):
name = os.path.join(out_dir, "join_as_player%d.bat" % (i + 1))
with open(name, "w", newline="\r\n") as f:
f.write("@echo off\n"
"rem BT411 -- join %s's game as player %d (%s)\n"
"set BT_RELAY=%s:%d\n"
"set BT_SELF=%s\n"
"cd /d %%~dp0content\n"
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
% (host, i + 1, p.get("vehicle") or "?",
host, self.f_port.value(), p["address"],
os.path.basename(self.egg_path)))
self.log.appendPlainText(
"exported %d player script(s) to %s (relay host: %s)"
% (len(self.egg.pilots()), out_dir, host))
# ------------------------------------------------------------- closing --
def closeEvent(self, event):
self._stop_session()
event.accept()
def dark_palette(app):
app.setStyle("Fusion")
p = QPalette()
bg, panel, text = QColor(37, 37, 40), QColor(28, 28, 30), QColor(220, 220, 220)
p.setColor(QPalette.Window, bg)
p.setColor(QPalette.WindowText, text)
p.setColor(QPalette.Base, panel)
p.setColor(QPalette.AlternateBase, bg)
p.setColor(QPalette.Text, text)
p.setColor(QPalette.Button, bg)
p.setColor(QPalette.ButtonText, text)
p.setColor(QPalette.Highlight, QColor(42, 130, 218))
p.setColor(QPalette.HighlightedText, Qt.white)
p.setColor(QPalette.ToolTipBase, panel)
p.setColor(QPalette.ToolTipText, text)
p.setColor(QPalette.PlaceholderText, QColor(120, 120, 120))
app.setPalette(p)
def main():
app = QApplication(sys.argv)
dark_palette(app)
win = Operator()
win.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env python
"""eggmodel.py -- headless mission-egg data model for the BT411 operator console.
Parses/edits/generates mission eggs with full preservation of the sections the
editor does not manage (bitmap rasters, role pages), and derives every legal
value set LIVE from content/BTL4.RES so the editor's dropdowns are authoritative:
- maps = resource names present as BOTH MakeMessageStream (14) AND
ExistanceBoxStream (26) -- the game aborts unless both exist
(MISSION.cpp:344-373)
- colors/badges/patches = the VehicleTable resource (type 25) [color]/[badge]/
[patch] sections (btl4vid.cpp SetupMaterialSubstitutionList)
- vehicles = ModelList (type 1) names, with the known-good mech models
surfaced first (egg 'vehicle=' resolves via
FindResourceDescription(..., ModelListResourceType))
Egg schema rules encoded here (readers: engine/MUNGA/MISSION.cpp ctor,
game/reconstructed/btl4mssn.cpp SetPlayerData):
[mission] REQUIRED: map, time, weather, scenario (missing => abort)
[pilots] pilot=<address> lines; each address names a [<address>] page
pilot page REQUIRED: hostType, vehicle, dropzone, color, patch, badge,
experience (unless vehicle=camera), role (-> a [role] page w/ model=)
badge doubles as the team name. experience in {novice,standard,veteran,expert}.
This module is GUI-free and importable (the console app and tests both use it).
Self-test: python eggmodel.py [--selftest]
"""
import collections
import os
import struct
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_RES = os.path.join(HERE, "..", "content", "BTL4.RES")
DEFAULT_TEMPLATE = os.path.join(HERE, "..", "content", "MP_BHMC.EGG")
# Static (observed / code-enumerated) value sets.
EXPERIENCES = ["novice", "standard", "veteran", "expert"] # btl4mssn.cpp:296-321
TIMES = ["night", "day"] # env-table tokens
WEATHERS = ["clear", "fog", "soup"] # env-table tokens
SCENARIOS = ["freeforall"] # observed
HOST_TYPES = {"0": "GameMachine", "1": "CameraShip", "2": "MissionReview"}
DROPZONES = ["one"] # only confirmed-valid name (embedded in map streams)
# Mech game models: known-good first (verified live), then candidates that ship
# skeleton/dead assets but are unverified as player vehicles.
KNOWN_GOOD_VEHICLES = ["bhk1", "madcat"]
CANDIDATE_VEHICLES = ["ava1", "avatar", "blkhawk", "lok1", "lok2", "loki",
"mad1", "mad2", "own1", "owens", "snd1", "sunder",
"thor", "thr1", "vul1", "vulture", "camera"]
RELAY_TAG_FMT = "10.99.0.{n}:1502" # relay-mode [pilots] identity tags
# ---------------------------------------------------------------------------
# BTL4.RES reader (the resscan.py parser, importable)
# ---------------------------------------------------------------------------
Res = collections.namedtuple("Res", "rid rtype name prio flags off length")
def read_res(path=DEFAULT_RES):
"""Parse BTL4.RES -> {rid: Res}."""
data = open(path, "rb").read()
_lab_only, max_id = struct.unpack_from("<ii", data, 4)
offsets = struct.unpack_from("<%dI" % max_id, data, 12)
resources = {}
for o in offsets:
if o == 0:
continue
rid, rtype = struct.unpack_from("<ii", data, o)
name = data[o + 8:o + 40].split(b"\0")[0].decode("ascii", "replace")
prio, flags, roff, rlen = struct.unpack_from("<iIII", data, o + 40)
resources[rid] = Res(rid, rtype, name, prio, flags, roff, rlen)
return data, resources
def _parse_table_text(text):
"""Parse the vehicletable resource: NUL-separated lines (the NotationFile
wire format), ';' comment lines, '//' trailing comments."""
sections = {}
current = None
for raw in text.replace("\0", "\n").replace("\r", "\n").split("\n"):
line = raw.split("//")[0].strip()
if not line or line.startswith(";"):
continue
if line.startswith("[") and line.endswith("]"):
current = line[1:-1].strip().lower()
sections[current] = collections.OrderedDict()
elif "=" in line and current is not None:
key, _, value = line.partition("=")
sections[current][key.strip()] = value.strip()
return sections
class ValueSets:
"""Every legal dropdown value, read live from BTL4.RES."""
def __init__(self, res_path=DEFAULT_RES):
data, resources = read_res(res_path)
by_type = collections.defaultdict(list)
for r in resources.values():
by_type[r.rtype].append(r)
# maps: need BOTH type 14 and type 26
maps14 = {r.name for r in by_type[14]}
maps26 = {r.name for r in by_type[26]}
self.maps = sorted(maps14 & maps26)
# vehicletable (type 25)
self.colors, self.badges, self.patches = [], [], []
for r in by_type[25]:
text = data[r.off:r.off + r.length].decode("latin-1", "replace")
table = _parse_table_text(text)
self.colors = list(table.get("color", {}).keys())
self.badges = list(table.get("badge", {}).keys())
self.patches = list(table.get("patch", {}).keys())
# vehicles: known-good first, then candidate mechs present in type 1
type1 = {r.name for r in by_type[1]}
self.vehicles = [v for v in KNOWN_GOOD_VEHICLES if v in type1]
self.vehicles += [v for v in CANDIDATE_VEHICLES
if v in type1 and v not in self.vehicles]
self.all_models = sorted(type1)
self.experiences = list(EXPERIENCES)
self.times = list(TIMES)
self.weathers = list(WEATHERS)
self.scenarios = list(SCENARIOS)
self.dropzones = list(DROPZONES)
# ---------------------------------------------------------------------------
# Egg document: order-preserving section parse/emit
# ---------------------------------------------------------------------------
class EggDoc:
"""An egg as an ordered list of (section_name, [raw 'key=value' lines]).
Lines before the first [section] live under section name None. Everything
the editor does not manage (bitmap rasters, role pages, ordinals) survives
byte-identically.
"""
def __init__(self, sections=None):
self.sections = sections if sections is not None else []
# ---- parse / emit ----
@classmethod
def load(cls, path):
return cls.parse(open(path, "r", encoding="latin-1").read())
@classmethod
def parse(cls, text):
sections = []
name, lines = None, []
for raw in text.replace("\r\n", "\n").split("\n"):
line = raw.rstrip()
if line.startswith("[") and line.endswith("]"):
sections.append((name, lines))
name, lines = line[1:-1], []
else:
if line:
lines.append(line)
sections.append((name, lines))
return cls(sections)
def emit(self):
out = []
for name, lines in self.sections:
if name is not None:
out.append("[%s]" % name)
out.extend(lines)
return "\n".join(out) + "\n"
def save(self, path):
with open(path, "w", encoding="latin-1", newline="\n") as f:
f.write(self.emit())
# ---- section access ----
def index_of(self, name):
for i, (n, _) in enumerate(self.sections):
if n is not None and n.lower() == name.lower():
return i
return -1
def get_lines(self, name):
i = self.index_of(name)
return self.sections[i][1] if i >= 0 else None
def get_kv(self, section, key):
lines = self.get_lines(section)
if lines is None:
return None
for line in lines:
k, _, v = line.partition("=")
if k.strip().lower() == key.lower():
return v.strip()
return None
def set_kv(self, section, key, value):
i = self.index_of(section)
if i < 0:
self.sections.append((section, ["%s=%s" % (key, value)]))
return
name, lines = self.sections[i]
for j, line in enumerate(lines):
k, _, _v = line.partition("=")
if k.strip().lower() == key.lower():
lines[j] = "%s=%s" % (key, value)
return
lines.append("%s=%s" % (key, value))
def remove_section(self, name):
i = self.index_of(name)
if i >= 0:
del self.sections[i]
def replace_section(self, name, lines, after=None):
"""Replace (or insert after section `after`) a whole section."""
i = self.index_of(name)
if i >= 0:
self.sections[i] = (name, list(lines))
return
pos = len(self.sections)
if after is not None:
j = self.index_of(after)
if j >= 0:
pos = j + 1
self.sections.insert(pos, (name, list(lines)))
# ---- the managed model ----
MISSION_KEYS = ["adventure", "map", "scenario", "time", "weather",
"temperature", "length"]
PILOT_KEYS = ["hostType", "advancedDamage", "loadzones", "name",
"bitmapindex", "experience", "badge", "patch",
"role", "dropzone", "vehicle", "vehicleValue", "color"]
def mission(self):
return {k: self.get_kv("mission", k) for k in self.MISSION_KEYS}
def pilots(self):
"""[{'address':..., <pilot keys>...}] in [pilots] order."""
lines = self.get_lines("pilots") or []
out = []
for line in lines:
k, _, v = line.partition("=")
if k.strip().lower() != "pilot":
continue
address = v.strip()
entry = {"address": address}
for key in self.PILOT_KEYS:
entry[key] = self.get_kv(address, key)
out.append(entry)
return out
def set_mission(self, **kv):
for k, v in kv.items():
if v is not None and v != "":
self.set_kv("mission", k, v)
def set_pilots(self, pilots):
"""Rewrite [pilots] + every per-pilot page from a pilots() -shaped list.
Old pilot pages are removed; page order matches roster order; everything
else in the egg is untouched."""
for old in self.pilots():
self.remove_section(old["address"])
self.replace_section(
"pilots", ["pilot=%s" % p["address"] for p in pilots])
anchor = "pilots"
for p in pilots:
lines = []
for key in self.PILOT_KEYS:
value = p.get(key)
if value is not None and value != "":
lines.append("%s=%s" % (key, value))
self.replace_section(p["address"], lines, after=anchor)
anchor = p["address"]
# ---- validation (the crash-prevention layer) ----
def validate(self, values: "ValueSets"):
"""Return a list of problem strings; empty == launchable."""
problems = []
m = self.mission()
for key in ("map", "time", "weather", "scenario"):
if not m.get(key):
problems.append("[mission] missing required key '%s'" % key)
if m.get("map") and m["map"] not in values.maps:
problems.append("map '%s' not in BTL4.RES (legal: %s)"
% (m["map"], ", ".join(values.maps)))
pilots = self.pilots()
if not pilots:
problems.append("no [pilots] entries")
seen = set()
for p in pilots:
a = p["address"]
if a in seen:
problems.append("duplicate pilot address '%s'" % a)
seen.add(a)
if self.index_of(a) < 0:
problems.append("pilot '%s' has no [%s] page" % (a, a))
continue
is_camera = (p.get("vehicle") == "camera")
required = ["hostType", "vehicle", "dropzone", "color",
"patch", "badge", "role"]
if not is_camera:
required.append("experience")
for key in required:
if not p.get(key):
problems.append("pilot '%s' missing required '%s'" % (a, key))
if p.get("color") and p["color"] not in values.colors:
problems.append("pilot '%s' color '%s' illegal (legal: %s)"
% (a, p["color"], ", ".join(values.colors)))
if p.get("badge") and p["badge"] not in values.badges:
problems.append("pilot '%s' badge '%s' illegal" % (a, p["badge"]))
if p.get("patch") and p["patch"] not in values.patches:
problems.append("pilot '%s' patch '%s' illegal" % (a, p["patch"]))
if p.get("experience") and p["experience"] not in values.experiences:
problems.append("pilot '%s' experience '%s' illegal (legal: %s)"
% (a, p["experience"], ", ".join(values.experiences)))
role = p.get("role")
if role and self.index_of(role) < 0:
problems.append("pilot '%s' role page [%s] missing" % (a, role))
elif role and not self.get_kv(role, "model"):
problems.append("role page [%s] missing 'model='" % role)
return problems
def new_from_template(template_path=DEFAULT_TEMPLATE):
"""A fresh editable egg seeded from the shipped template (keeps the bitmap
rasters + role pages the editor never touches)."""
return EggDoc.load(template_path)
def relay_tags(count):
return [RELAY_TAG_FMT.format(n=i + 1) for i in range(count)]
# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------
def _selftest():
ok = True
def check(cond, what):
nonlocal ok
print(" [%s] %s" % ("ok" if cond else "FAIL", what))
ok = ok and cond
values = ValueSets()
check(values.maps == ["arena1", "arena2", "artrucks", "cavern", "dbase",
"grass", "polar3", "polar4", "rav"],
"maps from RES: %s" % values.maps)
check(values.colors == ["Black", "Brown", "Crimson", "Green", "Grey",
"Tan", "White"], "colors: %s" % values.colors)
check(len(values.badges) == 7 and "VGL" in values.badges,
"badges: %s" % values.badges)
check(len(values.patches) == 8 and "Yellow" in values.patches,
"patches: %s" % values.patches)
check(values.vehicles[:2] == ["bhk1", "madcat"],
"vehicles (known-good first): %s" % values.vehicles[:6])
# Round-trip the shipped template: parse -> emit -> parse, semantics equal.
doc = EggDoc.load(DEFAULT_TEMPLATE)
doc2 = EggDoc.parse(doc.emit())
check(doc.mission() == doc2.mission(), "mission round-trips")
check(doc.pilots() == doc2.pilots(), "pilots round-trip")
check(len(doc.sections) == len(doc2.sections),
"section count preserved (%d)" % len(doc.sections))
check(doc.validate(values) == [],
"shipped template validates clean: %s" % doc.validate(values))
# Build a fresh 3-player relay egg and validate.
egg = new_from_template()
egg.set_mission(map="arena1", time="day", weather="fog")
tags = relay_tags(3)
base = doc.pilots()[0]
pilots = []
for i, tag in enumerate(tags):
p = dict(base)
p["address"] = tag
p["vehicle"] = ["bhk1", "madcat", "bhk1"][i]
p["color"] = ["White", "Crimson", "Green"][i]
pilots.append(p)
egg.set_pilots(pilots)
check(egg.validate(values) == [],
"generated 3-player relay egg validates: %s" % egg.validate(values))
check(len(egg.pilots()) == 3, "3 pilots emitted")
check(egg.get_kv(tags[2], "color") == "Green", "per-pilot page rewritten")
# bitmap sections survive
check(egg.index_of("largebitmap") >= 0 and egg.index_of("ordinals") >= 0,
"bitmap/ordinal sections preserved")
# bad values are caught
egg.set_kv(tags[0], "color", "Red")
check(any("Red" in p for p in egg.validate(values)),
"illegal color=Red caught by validation")
print("\n%s" % ("ALL PASS" if ok else "FAILURES PRESENT"))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(_selftest())