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:
co-authored by
Claude Fable 5
parent
dbb9af2dfa
commit
43fa53d542
@@ -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
|
||||
@@ -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())
|
||||
@@ -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> %s"
|
||||
% (head, " ".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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user