The 1995 console let the operator type player callsigns; the pods render them from 1bpp BITMAPS streamed in the egg ([largebitmap]/[smallbitmap] lists + hex raster pages, bitmapindex per pilot; large 128x32 on the score/ranking display via DPLRenderer name textures, small 64x16 on the kill-feed and radar/target labels). The textual name= pilot key is dead in the shipped binary -- its pilot parse reads bitmapindex only (decomp part_014). Historical bug surfaced by this work: every egg in the repo listed only ONE name bitmap (Aeolus) while pilot 2 pointed at index 2, so player 2+'s callsign has never rendered (null-checked, silently blank). eggmodel.py: EggDoc.set_callsigns rewrites the lists + one raster page per callsign per size + per-pilot name=/bitmapindex= (duplicate names get disambiguated page tags); get_callsign_rows decodes pages back (exact inverse of the engine's nibble-stream parse, GRAPH2D.cpp:340, MSB-first bit order :558); rasterize_callsign/make_callsigns render text via Qt (auto-shrink to fit, NoAntialias, 1bpp threshold) -- the only PySide6-dependent corner of the module. validate() now catches dangling bitmapindexes and missing raster pages. btoperator.py: Callsign column in the roster (free text, PLAYERn default); every save rasterizes the column into the egg exactly like the original console did. MP_BHMC.EGG template repaired: authentic Aeolus raster preserved bit-exact, Boreas page generated and listed. Verified: eggmodel self-test 20/20 (bit-exact raster round-trip, dup disambiguation, dangling-index catch); ASCII-art decode of the app-generated VIPER/MONGOOSE rasters reads correctly; live 2-node relay session on the generated egg -- full ladder, launch, zero name-bitmap load warnings; operator GUI e2e PASS after the column shift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
755 lines
30 KiB
Python
755 lines
30 KiB
Python
#!/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"pod ACK from .*?\((\d+)/(\d+) ready\)")
|
|
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_RELAY_READY = re.compile(r"WAITING FOR OPERATOR")
|
|
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.ready = False # manual mode: awaiting operator
|
|
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
|
|
if self.RE_RELAY_READY.search(line):
|
|
self.ready = True
|
|
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", "Callsign", "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")
|
|
self.f_manual = QCheckBox("Manual launch (wait for my Launch button)")
|
|
self.f_manual.setChecked(True)
|
|
nform.addRow("Console port", self.f_port)
|
|
nform.addRow("Public host", self.f_public)
|
|
nform.addRow(self.f_manual)
|
|
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.restart_btn = QPushButton("↻ Restart session")
|
|
self.restart_btn.clicked.connect(self._restart_session)
|
|
self.restart_btn.setEnabled(False)
|
|
self.launch_btn = QPushButton("🚀 LAUNCH MISSION")
|
|
self.launch_btn.setStyleSheet(
|
|
"font-weight:bold; color:#33cc55; padding:4px 14px;")
|
|
self.launch_btn.clicked.connect(self._launch_mission)
|
|
self.launch_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.addWidget(self.restart_btn)
|
|
srow.addSpacing(20)
|
|
srow.addWidget(self.launch_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)
|
|
# callsign: free text, becomes the name BITMAPS in the egg on save
|
|
# (the 1995 console's job -- see eggmodel.set_callsigns)
|
|
self.table.setItem(r, 1, QTableWidgetItem(
|
|
pilot.get("name") or "PLAYER%d" % (r + 1)))
|
|
self.table.setCellWidget(r, 2, self._combo(
|
|
self.values.vehicles, pilot.get("vehicle") or "bhk1"))
|
|
self.table.setCellWidget(r, 3, self._combo(
|
|
self.values.colors, pilot.get("color") or "White"))
|
|
self.table.setCellWidget(r, 4, self._combo(
|
|
self.values.badges, pilot.get("badge") or "VGL"))
|
|
self.table.setCellWidget(r, 5, self._combo(
|
|
self.values.patches, pilot.get("patch") or "Yellow"))
|
|
self.table.setCellWidget(r, 6, 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, 7, 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["name"] = self._row_callsign(r)
|
|
p["vehicle"] = self.table.cellWidget(r, 2).currentText()
|
|
p["color"] = self.table.cellWidget(r, 3).currentText()
|
|
p["badge"] = self.table.cellWidget(r, 4).currentText()
|
|
p["patch"] = self.table.cellWidget(r, 5).currentText()
|
|
p["experience"] = self.table.cellWidget(r, 6).currentText()
|
|
p["bitmapindex"] = str(r + 1)
|
|
return p
|
|
|
|
def _row_callsign(self, r):
|
|
item = self.table.item(r, 1)
|
|
text = item.text().strip() if item else ""
|
|
return text or "PLAYER%d" % (r + 1)
|
|
|
|
def _row_local(self, r):
|
|
wrap = self.table.cellWidget(r, 7)
|
|
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
|
|
p.pop("name", None) # new row gets the PLAYERn default callsign
|
|
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)
|
|
# rasterize the callsigns into the egg's name-bitmap pages (what the
|
|
# 1995 console did when the operator typed a callsign)
|
|
self.egg.set_callsigns(eggmodel.make_callsigns(
|
|
[self._row_callsign(r) for r in range(self.table.rowCount())]))
|
|
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]
|
|
if self.f_manual.isChecked():
|
|
args += ["--manual-launch"]
|
|
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.restart_btn.setEnabled(True)
|
|
self.launch_local_btn.setEnabled(True)
|
|
self.launch_btn.setEnabled(False)
|
|
self.log.appendPlainText("== session started (%s%s) ==" %
|
|
("relay" if relay else "mesh",
|
|
", manual launch" if relay
|
|
and self.f_manual.isChecked() else ""))
|
|
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.restart_btn.setEnabled(False)
|
|
self.launch_btn.setEnabled(False)
|
|
self.launch_local_btn.setEnabled(False)
|
|
self.pod_status.setText("session not running")
|
|
self.log.appendPlainText("== session stopped ==")
|
|
|
|
def _restart_session(self):
|
|
"""Next round: same mission/roster, fresh session. Players just
|
|
re-run their join script."""
|
|
self.log.appendPlainText("== restarting session ==")
|
|
self._stop_session()
|
|
self._start_session()
|
|
|
|
def _launch_mission(self):
|
|
"""Manual launch gate: send the relay its 'launch' command."""
|
|
if self.console_proc:
|
|
self.console_proc.write(b"launch\n")
|
|
self.launch_btn.setEnabled(False)
|
|
self.log.appendPlainText(">> operator LAUNCH sent")
|
|
|
|
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))
|
|
if self.monitor.launched:
|
|
head = "LAUNCHED — mission running"
|
|
elif self.monitor.ready:
|
|
head = "ALL PODS READY — press LAUNCH MISSION"
|
|
elif self.monitor.relay_mode:
|
|
head = "eggs delivered: %d/%d" % (self.monitor.eggs,
|
|
len(self.monitor.tags))
|
|
else:
|
|
head = "mesh console driving pods"
|
|
self.pod_status.setText("<b>%s</b> %s"
|
|
% (head, " ".join(parts)))
|
|
self.launch_btn.setEnabled(
|
|
self.monitor.ready and not self.monitor.launched
|
|
and self.console_proc is not None)
|
|
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
|
|
egg_name = os.path.basename(self.egg_path)
|
|
# ONE universal script per transport: the RELAY assigns seats
|
|
# first-come-first-served (no BT_SELF), so every player gets the SAME
|
|
# file -- who flies which mech is whoever sits down first, exactly
|
|
# like walking up to a pod.
|
|
with open(os.path.join(out_dir, "join.bat"), "w", newline="\r\n") as f:
|
|
f.write("@echo off\n"
|
|
"rem BT411 -- join %s's game (internet). Your seat/mech\n"
|
|
"rem is assigned automatically by the operator console.\n"
|
|
"set BT_RELAY=%s:%d\n"
|
|
"cd /d %%~dp0content\n"
|
|
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
|
|
% (host, host, self.f_port.value(), egg_name))
|
|
with open(os.path.join(out_dir, "join_lan.bat"), "w",
|
|
newline="\r\n") as f:
|
|
f.write("@echo off\n"
|
|
"rem BT411 -- join the LAN game. Finds the operator\n"
|
|
"rem console AND your seat automatically.\n"
|
|
"set BT_RELAY=auto\n"
|
|
"cd /d %%~dp0content\n"
|
|
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
|
|
% egg_name)
|
|
self.log.appendPlainText(
|
|
"exported join.bat + join_lan.bat to %s (public host: %s) -- "
|
|
"one file for everyone; the relay assigns seats"
|
|
% (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())
|