Completes the travelling-operator deployment. The relay stays parked on the
home machine (so no player ever edits join.bat) and the console dials in from
anywhere -- but until now two things needed hands on that machine: bringing the
relay back when it died, and restarting it (the only way to clear a wedge or
pick up a roster resize). Both are covered now.
tools/btrelay_park.py -- the supervisor:
* runs the relay with cwd=content\ , which is what pins WHICH
operator_secret.txt is live (the trap documented last commit);
* relaunches on ANY exit, with 2/5/15/30/60s backoff when a relay dies inside
20s, so a permanent fault (port taken, missing egg) cannot become a spin;
* rotates content\parked_relay.log to .1 first, so the dead generation's
evidence survives the relaunch that replaces it;
* Ctrl-C stops it for good. NOT a Windows service on purpose: session 0
would hide the window an operator wants to tail.
tools/park_relay.cmd -- double-click to park; a shortcut to it in shell:startup
gives start-after-reboot with no admin rights.
`restart` on the control port (btconsole.py): sets restart_requested, the run
loop returns, relay_main exits RESTART_EXIT_CODE=42 and the supervisor relaunches
-- re-reading the egg. REFUSED while a mission is running (launches_sent >= 2
and not stop_sent): bouncing then would drop every pod out of a live round.
Local stdin gets the same command for parity.
btoperator.py: Restart Session in REMOTE mode now sends `restart` and reconnects
6s later instead of just tearing down our own link -- the old behaviour looked
like "Restart does nothing" against a parked relay. (QTimer had to be imported;
it was missing, which py_compile does not catch -- it would have been a runtime
NameError on the first click.)
VERIFIED by scratchpad/test_parked_supervisor.py, 12/12, and the full
test_remote_console_e2e.py re-run green afterwards (15/15):
* kill the relay -> a NEW pid is serving again, and the old log is kept as .1;
* remote `restart` -> ACKed, new pid, egg re-read, serving again;
* the supervisor distinguishes the two -- "operator requested a restart" vs
the crash/backoff path -- proven from its own log, not assumed;
* the mid-mission guard is present and wired to launches_sent/stop_sent.
Two harness defects fixed while proving it, both mine: a check written with
`or True` that could never fail (replaced with two real assertions against the
supervisor's log), and a recv that treated the relay's correct
close-after-restart as a ConnectionResetError failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1465 lines
66 KiB
Python
1465 lines
66 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 socket
|
|
import sys
|
|
import threading
|
|
|
|
from PySide6.QtCore import (Qt, QProcess, QProcessEnvironment, QThread, QTimer,
|
|
Signal)
|
|
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_SEATED = "seated"
|
|
ST_READY = "ready"
|
|
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",
|
|
ST_SEATED: "#268bd2", ST_READY: "#8be000"}
|
|
|
|
|
|
class SessionMonitor:
|
|
"""Turns btconsole/relay stdout lines into per-pilot state + stats."""
|
|
|
|
RE_RELAY_EGG = re.compile(r"pod ACK from .*?\((\d+)/(\d+) ready\)")
|
|
# REGISTERED/dropped carry an explicit tag='...' since 2026-07-26: the
|
|
# positional hostID->tag fallback is computed against the UNTRIMMED egg
|
|
# roster, so after a LAUNCH trim remaps seat ids it names the wrong row.
|
|
# Prefer the relay's own tag; keep the fallback for old logs.
|
|
RE_RELAY_REG = re.compile(
|
|
r"game\[.* host=(\d+)\] REGISTERED(?:.*?tag='([^']*)')?")
|
|
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(?:.*?tag='([^']*)')?")
|
|
RE_RELAY_WAITOP = re.compile(r"WAITING FOR OPERATOR")
|
|
RE_RELAY_SEAT = re.compile(
|
|
r"PLAYER (\d+) SEATED \(host \d+\) tag='([^']*)' "
|
|
r"callsign='([^']*)' mech='([^']*)'")
|
|
RE_RELAY_FREED = re.compile(
|
|
r"PLAYER (\d+) LEFT \(host \d+\) tag='([^']*)'")
|
|
RE_RELAY_READY = re.compile(
|
|
r"PLAYER (\d+) READY \(host \d+\) tag='([^']*)'")
|
|
RE_RELAY_HELD = re.compile(
|
|
r"launch HELD -- still loading: (.+) \((\d+)/(\d+) ready\)")
|
|
RE_RELAY_RESET = re.compile(r"round RESET")
|
|
# The relay announces its roster at startup and replays that line to a
|
|
# newly AUTHed operator. A REMOTE operator has no egg loaded, so this is
|
|
# the only way it can learn the tags -- without them `state` stayed empty,
|
|
# every pilot light was invisible and `seated` was always 0.
|
|
RE_RELAY_ROSTER = re.compile(
|
|
r"roster: (\d+) pilot\(s\) -> hostIDs \[[^\]]*\]: \[(.*)\]")
|
|
# A ROUND HAS ENDED / THE LAUNCHER RE-ARMED. Before these, `launched`
|
|
# could only be cleared by "WAITING FOR OPERATOR" (needs every seat of the
|
|
# last round to re-ACK) or "round RESET" (needs every pod gone). A games
|
|
# night sits between those -- one player does not come back -- so the
|
|
# button stayed greyed out and the operator had to restart the session
|
|
# (report 2026-07-25). StopMission is the honest "the mission is over"
|
|
# signal, and RE-ARM is the relay saying the launcher is ready again.
|
|
RE_RELAY_REARM = re.compile(r"RE-ARM for a new round")
|
|
RE_RELAY_STOPPED = re.compile(r"StopMission sent")
|
|
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 = ""
|
|
self.seat_info = {} # tag -> (callsign, mech) requests
|
|
self.held_status = None # "PLAYER 2, 3 (2/4 ready)" while staging
|
|
|
|
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_ROSTER.search(line)
|
|
if m:
|
|
tags = re.findall(r"'([^']*)'", m.group(2))
|
|
if tags and tags != self.tags:
|
|
# keep any state we already know for a tag that survives
|
|
self.tags = tags
|
|
self.state = {t: self.state.get(t, ST_IDLE) for t in tags}
|
|
changed = True
|
|
if self.RE_RELAY_WAITOP.search(line):
|
|
# WAITING FOR OPERATOR = pods are ready to launch. Reset
|
|
# `launched` so the LAUNCH button re-enables -- this fires
|
|
# again for a NEW mission after the pods rejoin (back-to-back
|
|
# missions, 2026-07-18), not only the first time.
|
|
self.ready = True
|
|
self.launched = False
|
|
changed = True
|
|
m = self.RE_RELAY_SEAT.search(line)
|
|
if m:
|
|
tag = m.group(2)
|
|
if tag in self.state:
|
|
self.state[tag] = ST_SEATED
|
|
self.seat_info[tag] = (m.group(3), m.group(4))
|
|
changed = True
|
|
m = self.RE_RELAY_FREED.search(line)
|
|
if m:
|
|
tag = m.group(2)
|
|
if tag in self.state:
|
|
self.state[tag] = ST_IDLE
|
|
self.seat_info.pop(tag, None)
|
|
changed = True
|
|
m = self.RE_RELAY_READY.search(line)
|
|
if m:
|
|
tag = m.group(2)
|
|
if tag in self.state:
|
|
self.state[tag] = ST_READY
|
|
changed = True
|
|
m = self.RE_RELAY_REG.search(line)
|
|
if m:
|
|
tag = m.group(2) or self.host_tag(int(m.group(1)))
|
|
if tag in self.state and self.state.get(tag) != ST_READY:
|
|
self.state[tag] = ST_REGISTERED
|
|
changed = True
|
|
m = self.RE_RELAY_DOWN.search(line)
|
|
if m:
|
|
tag = m.group(2) or self.host_tag(int(m.group(1)))
|
|
if tag in self.state and self.state.get(tag) != ST_IDLE:
|
|
self.state[tag] = ST_IDLE
|
|
# ALSO forget the walk-up identity. "PLAYER n LEFT" -- the
|
|
# other line that clears a seat -- is only printed by the
|
|
# relay when the player never CLAIMED its seat, so a
|
|
# registered player dropping mid-session produced only this
|
|
# `dropped` line. Without the pop, seat_info kept their
|
|
# callsign and the roster showed a departed pilot as still
|
|
# sitting there (operator report 2026-07-26).
|
|
self.seat_info.pop(tag, None)
|
|
changed = True
|
|
m = self.RE_RELAY_HELD.search(line)
|
|
if m:
|
|
self.held_status = "%s still loading (%s/%s ready)" % (
|
|
m.group(1), m.group(2), m.group(3))
|
|
changed = True
|
|
if (self.RE_RELAY_REARM.search(line)
|
|
or self.RE_RELAY_STOPPED.search(line)):
|
|
# The round is over (or the relay re-armed): the LAUNCH button
|
|
# must come back WITHOUT needing a full-roster re-ACK.
|
|
self.launched = False
|
|
self.held_status = None
|
|
changed = True
|
|
if self.RE_RELAY_RESET.search(line):
|
|
# BETWEEN ROUNDS: re-arm the console for the next launch. The
|
|
# old re-arm rode "WAITING FOR OPERATOR" which only prints on a
|
|
# FULL-roster ACK -- in launch-with-whoever mode the button
|
|
# stayed dead after round 1 (field report 2026-07-23).
|
|
self.launched = False
|
|
self.ready = False
|
|
self.held_status = None
|
|
changed = True
|
|
m = self.RE_RELAY_RUN.search(line)
|
|
if m and int(m.group(1)) >= 2:
|
|
self.launched = True
|
|
self.held_status = None
|
|
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 RemoteRelayLink(QThread):
|
|
"""Operator control-channel client (2026-07-24): drives a RELAY RUNNING
|
|
ON ANOTHER MACHINE over the btconsole control protocol (AUTH, then the
|
|
relay's log stream in; launch/stop/set commands out). The reader runs
|
|
in this thread; send() is called from the GUI thread -- socket sendall
|
|
on small lines is safe cross-thread, guarded anyway."""
|
|
|
|
lineReceived = Signal(str)
|
|
closed = Signal(str)
|
|
|
|
def __init__(self, host, port, secret):
|
|
super().__init__()
|
|
self.host = host
|
|
self.port = port
|
|
self.secret = secret
|
|
self.sock = None
|
|
self._send_lock = threading.Lock()
|
|
self._stopping = False
|
|
|
|
def run(self):
|
|
try:
|
|
self.sock = socket.create_connection((self.host, self.port),
|
|
timeout=8)
|
|
self.sock.sendall(("AUTH %s\n" % self.secret).encode())
|
|
self.sock.settimeout(None)
|
|
buf = b""
|
|
while not self._stopping:
|
|
data = self.sock.recv(4096)
|
|
if not data:
|
|
break
|
|
buf += data
|
|
while b"\n" in buf:
|
|
line, buf = buf.split(b"\n", 1)
|
|
text = line.decode("utf-8", "replace").rstrip()
|
|
if text:
|
|
self.lineReceived.emit(text)
|
|
self.closed.emit("connection closed by relay"
|
|
if not self._stopping else "disconnected")
|
|
except Exception as e:
|
|
self.closed.emit("connect/read failed: %r" % e)
|
|
|
|
def send(self, text):
|
|
with self._send_lock:
|
|
if self.sock is None:
|
|
return False
|
|
try:
|
|
self.sock.sendall((text.rstrip() + "\n").encode())
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
def stop(self):
|
|
self._stopping = True
|
|
with self._send_lock:
|
|
if self.sock is not None:
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
self.sock = None
|
|
|
|
|
|
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.remote_link = None
|
|
# row -> (configured callsign, configured mech), stashed while that seat
|
|
# is occupied by a walk-up player and restored when it empties, so a
|
|
# departed pilot's name does not sit in the roster (report 2026-07-26).
|
|
self._seat_defaults = {}
|
|
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)
|
|
# REMOTE OPERATOR (2026-07-24): leave blank to run the relay HERE
|
|
# (unchanged local flow -- relay dies with this window). Enter a
|
|
# host to DRIVE a relay running on that machine instead (needs its
|
|
# content\operator_secret.txt value; only the relay host forwards
|
|
# ports).
|
|
top.addSpacing(12)
|
|
top.addWidget(QLabel("Relay host:"))
|
|
self.f_relayhost = QLineEdit()
|
|
self.f_relayhost.setPlaceholderText("blank = run relay here")
|
|
self.f_relayhost.setFixedWidth(150)
|
|
self.f_relayhost.setToolTip(
|
|
"Blank/localhost: start the relay on this machine (it stops when "
|
|
"you close this window).\nA hostname/IP: connect to a relay "
|
|
"already running there and operate it remotely.")
|
|
top.addWidget(self.f_relayhost)
|
|
top.addWidget(QLabel("Secret:"))
|
|
self.f_secret = QLineEdit()
|
|
self.f_secret.setPlaceholderText("remote only")
|
|
self.f_secret.setFixedWidth(120)
|
|
self.f_secret.setToolTip(
|
|
"The relay host's content\\operator_secret.txt value "
|
|
"(auto-created there on first relay start).")
|
|
top.addWidget(self.f_secret)
|
|
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()
|
|
# RELAY mode (2026-07-22): rows are just CAPACITY + per-seat defaults
|
|
# -- joiners auto-populate them live and launch trims to whoever is
|
|
# seated, so hand add/remove is busywork. A Seats spinner maintains
|
|
# the row count; Add/Remove stay for MESH mode (explicit addresses).
|
|
self.seats_label = QLabel("Seats:")
|
|
self.seats_spin = QSpinBox()
|
|
self.seats_spin.setRange(1, 8)
|
|
self.seats_spin.valueChanged.connect(self._set_seat_count)
|
|
self.add_btn = QPushButton("Add pilot")
|
|
self.add_btn.clicked.connect(self._add_pilot)
|
|
self.rem_btn = QPushButton("Remove selected")
|
|
self.rem_btn.clicked.connect(self._remove_pilot)
|
|
self.export_btn = QPushButton("Export player scripts…")
|
|
self.export_btn.clicked.connect(self._export_scripts)
|
|
btns.addWidget(self.seats_label); btns.addWidget(self.seats_spin)
|
|
btns.addWidget(self.add_btn); btns.addWidget(self.rem_btn)
|
|
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.apply_btn = QPushButton("Apply mission settings")
|
|
self.apply_btn.setToolTip(
|
|
"Write arena/time/weather/length changes into the running "
|
|
"session -- takes effect on the NEXT round (players keep their "
|
|
"seats between rounds)")
|
|
self.apply_btn.clicked.connect(self._apply_mission_settings)
|
|
self.apply_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.end_btn = QPushButton("⏹ END MISSION")
|
|
self.end_btn.setStyleSheet(
|
|
"font-weight:bold; color:#cc5533; padding:4px 14px;")
|
|
self.end_btn.clicked.connect(self._end_mission)
|
|
self.end_btn.setEnabled(False)
|
|
# ESCAPE HATCH (2026-07-26). When the launcher latched after a round,
|
|
# restarting the whole session was the ONLY way out -- which also
|
|
# disconnected every player who was still waiting. This asks the relay
|
|
# to clear just the round state and keep the seats.
|
|
self.rearm_btn = QPushButton("↻ Re-arm")
|
|
self.rearm_btn.setToolTip(
|
|
"Clear the finished round's state and re-open the launcher, "
|
|
"keeping everyone who is already connected. "
|
|
"Use this if LAUNCH looks dead after a round instead of "
|
|
"restarting the session. Between rounds only: refused while "
|
|
"a mission is running or a launch is firing.")
|
|
self.rearm_btn.clicked.connect(self._rearm_round)
|
|
self.rearm_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.addWidget(self.end_btn)
|
|
srow.addWidget(self.rearm_btn)
|
|
srow.addSpacing(20)
|
|
srow.addWidget(self.launch_local_btn)
|
|
srow.addWidget(self.apply_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
|
|
# a NEWLY-added pilot is a remote joiner by default (Local only on the
|
|
# operator's own seat, normally row 0 which the egg load already sets)
|
|
self._add_pilot_row(p, local=(self.mode.currentIndex() == 0
|
|
and self.table.rowCount() == 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 _set_seat_count(self, n):
|
|
if self.mode.currentIndex() != 0:
|
|
return
|
|
while self.table.rowCount() < n:
|
|
self._add_pilot()
|
|
while self.table.rowCount() > n:
|
|
self.table.removeRow(self.table.rowCount() - 1)
|
|
self._renumber_relay_tags()
|
|
|
|
def _apply_roster_controls_mode(self):
|
|
relay = self.mode.currentIndex() == 0
|
|
self.seats_label.setVisible(relay)
|
|
self.seats_spin.setVisible(relay)
|
|
self.add_btn.setVisible(not relay)
|
|
self.rem_btn.setVisible(not relay)
|
|
if relay:
|
|
self.seats_spin.blockSignals(True)
|
|
self.seats_spin.setValue(self.table.rowCount())
|
|
self.seats_spin.blockSignals(False)
|
|
|
|
def _mode_changed(self, _index):
|
|
relay = self.mode.currentIndex() == 0
|
|
self._apply_roster_controls_mode()
|
|
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())
|
|
|
|
def _apply_mission_settings(self):
|
|
"""Write the mission-parameter fields into the running session's egg
|
|
file; the relay re-reads it at the next round's egg release, so the
|
|
seated players get the new arena/params WITHOUT rejoining."""
|
|
if getattr(self, "remote_link", None):
|
|
spec = ("set map=%s;time=%s;weather=%s;scenario=%s;"
|
|
"temperature=%s;length=%s"
|
|
% (self.f_map.currentText(), self.f_time.currentText(),
|
|
self.f_weather.currentText(),
|
|
self.f_scenario.currentText(),
|
|
self.f_temp.text().strip(),
|
|
self.f_length.text().strip()))
|
|
self.remote_link.send(spec)
|
|
self.log.appendPlainText(">> mission settings sent to the remote "
|
|
"relay -- takes effect next round")
|
|
return
|
|
try:
|
|
doc = eggmodel.EggDoc.load(self.egg_path)
|
|
doc.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())
|
|
doc.save(self.egg_path)
|
|
self.log.appendPlainText(
|
|
">> mission settings applied (map=%s time=%s weather=%s "
|
|
"length=%s) -- takes effect next round"
|
|
% (self.f_map.currentText(), self.f_time.currentText(),
|
|
self.f_weather.currentText(), self.f_length.text().strip()))
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Apply",
|
|
"Could not update the egg:\n%r" % e)
|
|
|
|
# ------------------------------------------------------------ egg file --
|
|
|
|
def _load_egg_into_ui(self):
|
|
self._seat_defaults.clear() # the table is about to be rebuilt
|
|
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
|
|
# LOCAL default: ONLY the operator's own seat (row 0) -- NOT every row.
|
|
# (Checking Local on all rows made "Launch local instances" spawn one
|
|
# full game client PER seat on this machine, which collided and
|
|
# crashed -- field report 2026-07-18.) Remote joiners stay unchecked.
|
|
for i, p in enumerate(self.egg.pilots()):
|
|
self._add_pilot_row(p, local=(relay and i == 0))
|
|
self._renumber_relay_tags()
|
|
self._apply_roster_controls_mode() # Seats spinner mirrors rows
|
|
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 _restore_seat_defaults(self):
|
|
"""Write every stashed configured (callsign, mech) back into its row and
|
|
clear the stash. MUST run before anything snapshots or saves the table:
|
|
Restart Session used to _collect_egg() with a departed walk-up's name
|
|
still in the cell, baking it into the next session's egg (name= AND the
|
|
rasterized bitmaps) and then re-capturing it as the seat's 'configured
|
|
default' -- the reported roster bug returning through the egg file
|
|
(review 2026-07-26)."""
|
|
for tag, (callsign, mech) in list(self._seat_defaults.items()):
|
|
for r in range(self.table.rowCount()):
|
|
item = self.table.item(r, 0)
|
|
if item is None or item.text().strip() != tag:
|
|
continue
|
|
cell = self.table.item(r, 1)
|
|
if callsign is not None and cell is not None:
|
|
cell.setText(callsign)
|
|
combo = self.table.cellWidget(r, 2)
|
|
if mech is not None and combo is not None:
|
|
combo.setCurrentText(mech)
|
|
break
|
|
self._seat_defaults.clear()
|
|
|
|
def _start_session(self):
|
|
self.end_sent = False
|
|
# A session stopped while a seat was OCCUPIED leaves its stash entry
|
|
# behind (the seat never emptied, so it was never popped). Write those
|
|
# configured values BACK into the cells before _collect_egg can bake a
|
|
# walk-up's name into the egg, then start clean.
|
|
self._restore_seat_defaults()
|
|
relay_host = self.f_relayhost.text().strip()
|
|
if (self.mode.currentIndex() == 0 and relay_host
|
|
and relay_host.lower() not in ("localhost", "127.0.0.1")):
|
|
self._start_remote_session(relay_host)
|
|
return
|
|
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"]
|
|
# --reserve RETIRED (2026-07-22): it protected the operator's
|
|
# BT_SELF-pinned local seats from walk-up assignment, but local
|
|
# instances now request seats like every joiner -- reserving
|
|
# their seats locked THEM out ("ROSTER FULL" on the operator's
|
|
# own launch, field report). Seat order is first-come now; the
|
|
# trim-on-launch flow makes exact seats moot.
|
|
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))
|
|
try: # fresh relay log per session
|
|
open(os.path.join(CONTENT, "operator_relay.log"), "w").close()
|
|
except OSError:
|
|
pass
|
|
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.rearm_btn.setEnabled(True)
|
|
self.restart_btn.setEnabled(True)
|
|
self.launch_local_btn.setEnabled(True)
|
|
self.launch_btn.setEnabled(False)
|
|
self.apply_btn.setEnabled(True)
|
|
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 _start_remote_session(self, host):
|
|
secret = self.f_secret.text().strip()
|
|
if not secret:
|
|
QMessageBox.warning(self, "Remote relay",
|
|
"Enter the relay host's secret\n"
|
|
"(content\\operator_secret.txt on that "
|
|
"machine).")
|
|
return
|
|
ctl_port = self.f_port.value() + 7 # CONTROL_PORT_OFFSET
|
|
self.monitor = SessionMonitor(True, [])
|
|
self.remote_link = RemoteRelayLink(host, ctl_port, secret)
|
|
self.remote_link.lineReceived.connect(self._ingest_line)
|
|
self.remote_link.closed.connect(self._remote_closed)
|
|
self.remote_link.start()
|
|
self.remote_link.send("get mission")
|
|
self.start_btn.setEnabled(False)
|
|
self.stop_btn.setEnabled(True)
|
|
self.rearm_btn.setEnabled(True)
|
|
self.restart_btn.setEnabled(True)
|
|
self.launch_local_btn.setEnabled(True)
|
|
self.launch_btn.setEnabled(False)
|
|
self.apply_btn.setEnabled(True)
|
|
self.log.appendPlainText("== REMOTE session: driving relay at "
|
|
"%s:%d (the relay stays up when you "
|
|
"disconnect) ==" % (host, ctl_port))
|
|
self._refresh_pod_status()
|
|
|
|
def _remote_closed(self, reason):
|
|
self.log.appendPlainText("== remote relay link closed: %s ==" % reason)
|
|
self.remote_link = None
|
|
self.start_btn.setEnabled(True)
|
|
self.stop_btn.setEnabled(False)
|
|
self.rearm_btn.setEnabled(False)
|
|
self.restart_btn.setEnabled(False)
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn.setEnabled(False)
|
|
self.apply_btn.setEnabled(False)
|
|
self.pod_status.setText("remote link closed")
|
|
|
|
def _stop_session(self):
|
|
self._restore_seat_defaults() # departed walk-up names must not outlive the session
|
|
if getattr(self, "remote_link", None):
|
|
link = self.remote_link
|
|
self.remote_link = None # closed-signal becomes a no-op
|
|
link.closed.disconnect(self._remote_closed)
|
|
link.stop()
|
|
self.log.appendPlainText("== disconnected from remote relay "
|
|
"(the relay keeps running) ==")
|
|
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.rearm_btn.setEnabled(False)
|
|
self.restart_btn.setEnabled(False)
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn.setEnabled(False)
|
|
self.launch_local_btn.setEnabled(False)
|
|
self.pod_status.setText("session not running")
|
|
self.apply_btn.setEnabled(False)
|
|
self.log.appendPlainText("== session stopped ==")
|
|
|
|
def _restart_session(self):
|
|
"""Next round: same mission/roster, fresh session. Players just
|
|
re-run their join script."""
|
|
if getattr(self, "remote_link", None):
|
|
# PARKED RELAY: we do not own that process, so tearing down our
|
|
# own link would only disconnect us (the old behaviour, which
|
|
# looked like "Restart did nothing"). Ask the relay itself to
|
|
# restart -- its supervisor relaunches it, re-reading the egg, so
|
|
# a roster resize or a wedge is recoverable from the road. Then
|
|
# reconnect once it is back up.
|
|
host = self.f_relayhost.text().strip()
|
|
self.log.appendPlainText(
|
|
"== asking the parked relay at %s to RESTART "
|
|
"(reconnecting in a few seconds) ==" % host)
|
|
self._relay_send("restart")
|
|
self._stop_session()
|
|
QTimer.singleShot(6000, self._start_session)
|
|
return
|
|
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._relay_send("launch"):
|
|
# Grey the button only once the command actually LEFT. The refresh
|
|
# tick re-derives this from the relay's own reports, so if the relay
|
|
# declines the press the button comes back rather than staying dead
|
|
# (which is what forced a session restart before 2026-07-26).
|
|
self.launch_btn.setEnabled(False)
|
|
self.end_btn.setEnabled(True)
|
|
|
|
def _relay_send(self, command):
|
|
"""Send an operator command and report HONESTLY whether it went.
|
|
|
|
The old call sites wrote to the QProcess and logged success
|
|
unconditionally -- but _console_finished leaves console_proc non-None,
|
|
so after the relay died every press logged ">> sent" into the void.
|
|
"""
|
|
if getattr(self, "remote_link", None):
|
|
self.remote_link.send(command)
|
|
self.log.appendPlainText(">> operator %s sent (remote)"
|
|
% command.upper())
|
|
return True
|
|
proc = self.console_proc
|
|
if proc is None or proc.state() != QProcess.Running:
|
|
self.log.appendPlainText(
|
|
"!! %s NOT sent -- the console/relay is not running "
|
|
"(press Start Session)" % command.upper())
|
|
return False
|
|
proc.write((command + "\n").encode())
|
|
self.log.appendPlainText(">> operator %s sent" % command.upper())
|
|
return True
|
|
|
|
def _rearm_round(self):
|
|
"""Ask the relay to clear a finished round's state without a restart."""
|
|
self._relay_send("rearm")
|
|
|
|
def _end_mission(self):
|
|
"""End the running mission now (the relay also auto-stops at the
|
|
egg's [mission] length -- the authentic console mission clock)."""
|
|
if self._relay_send("stop"):
|
|
self.end_sent = True
|
|
self.end_btn.setEnabled(False)
|
|
|
|
def _console_finished(self):
|
|
self.log.appendPlainText("== console/relay process exited ==")
|
|
self.start_btn.setEnabled(True)
|
|
self.stop_btn.setEnabled(False)
|
|
self.rearm_btn.setEnabled(False)
|
|
|
|
def _console_output(self):
|
|
if not self.console_proc:
|
|
return
|
|
data = bytes(self.console_proc.readAllStandardOutput()).decode(
|
|
"utf-8", "replace")
|
|
# ALSO tee the relay/console output to a file -- the GUI log pane is
|
|
# not readable post-mortem, and every field bug so far needed the
|
|
# relay's own view to diagnose (2026-07-18).
|
|
try:
|
|
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
|
|
encoding="utf-8") as f:
|
|
f.write(data)
|
|
except OSError:
|
|
pass
|
|
for line in data.splitlines():
|
|
self._ingest_line(line)
|
|
|
|
def _ingest_line(self, line):
|
|
"""One relay/console log line -> log pane + tee file + monitor.
|
|
Shared by the local child-process path and the remote link."""
|
|
if not line.strip():
|
|
return
|
|
if getattr(self, "remote_link", None):
|
|
# remote lines aren't captured by the child-process tee above
|
|
try:
|
|
with open(os.path.join(CONTENT, "operator_relay.log"), "a",
|
|
encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
except OSError:
|
|
pass
|
|
self._maybe_fill_mission_fields(line)
|
|
self.log.appendPlainText(line)
|
|
if self.monitor and self.monitor.feed(line):
|
|
self._refresh_pod_status()
|
|
|
|
def _maybe_fill_mission_fields(self, line):
|
|
"""Prefill the mission form from the relay's 'get mission' reply."""
|
|
m = re.search(r"\[ctl\] mission map=(\S*) time=(\S*) "
|
|
r"weather=(\S*) scenario=(\S*) temperature=(\S*) "
|
|
r"length=(\S*)", line)
|
|
if not m:
|
|
return
|
|
for combo, value in ((self.f_map, m.group(1)),
|
|
(self.f_time, m.group(2)),
|
|
(self.f_weather, m.group(3)),
|
|
(self.f_scenario, m.group(4))):
|
|
if value:
|
|
combo.setCurrentText(value)
|
|
if m.group(5):
|
|
self.f_temp.setText(m.group(5))
|
|
if m.group(6):
|
|
self.f_length.setText(m.group(6))
|
|
|
|
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))
|
|
seated = sum(1 for s in self.monitor.state.values()
|
|
if s in (ST_SEATED, ST_REGISTERED, ST_READY))
|
|
ready_n = sum(1 for s in self.monitor.state.values() if s == ST_READY)
|
|
# END MISSION is PER ROUND, not per session. `end_sent` used to be
|
|
# cleared only by Start Session, so the button worked exactly once a
|
|
# night; and a stale press between rounds used to kill the next mission
|
|
# the instant it launched (the relay now refuses that too). This MUST
|
|
# sit outside the head-assignment chain below: as an `elif` it swallowed
|
|
# a branch and left `head` unbound -> UnboundLocalError on the first
|
|
# refresh after End Mission (review catch, 2026-07-26).
|
|
if not self.monitor.launched and getattr(self, "end_sent", False):
|
|
self.end_sent = False
|
|
|
|
if self.monitor.launched:
|
|
head = "LAUNCHED — mission running"
|
|
if (not self.end_btn.isEnabled()
|
|
and not getattr(self, "end_sent", False)
|
|
and (self.console_proc is not None
|
|
or getattr(self, "remote_link", None) is not None)):
|
|
self.end_btn.setEnabled(True)
|
|
elif self.monitor.held_status:
|
|
head = "STAGING — " + self.monitor.held_status
|
|
elif ready_n:
|
|
head = ("%d pod(s) LOADED+READY — LAUNCH starts them instantly"
|
|
% ready_n)
|
|
elif self.monitor.ready:
|
|
head = "ALL PODS READY — press LAUNCH MISSION"
|
|
elif seated:
|
|
head = ("%d player(s) seated (standing by) — LAUNCH starts "
|
|
"their loading; the mission fires itself when every "
|
|
"light is green" % seated)
|
|
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)))
|
|
# Reflect walk-up callsign/mech requests in the roster table -- AND put a
|
|
# seat back to its configured default when its player leaves.
|
|
#
|
|
# This used to only ever WRITE a name in: on a disconnect the relay pops
|
|
# seat_info, the loop hit `if not info: continue`, and the departed
|
|
# player's callsign sat in the roster for the rest of the session. The
|
|
# pilot light next to it correctly said "waiting", so the table and the
|
|
# light disagreed and the seat looked occupied when it was free
|
|
# (operator report 2026-07-26).
|
|
#
|
|
# Restoring the OPERATOR'S CONFIGURED value -- not a literal blank -- is
|
|
# deliberate: this cell is editable and is what gets written to the egg on
|
|
# Save, so stuffing a placeholder like "-- empty --" in here would put
|
|
# that placeholder into the mission file.
|
|
for r in range(self.table.rowCount()):
|
|
item = self.table.item(r, 0)
|
|
if item is None:
|
|
continue
|
|
tag = item.text().strip()
|
|
info = self.monitor.seat_info.get(tag)
|
|
cell = self.table.item(r, 1)
|
|
combo = self.table.cellWidget(r, 2)
|
|
if info:
|
|
# Occupied. On the FIRST refresh of this occupancy, stash what
|
|
# the operator had configured so we can put it back later. Doing
|
|
# it per-occupancy (rather than once at load) means an edit the
|
|
# operator makes while a seat is empty is respected. Keyed by
|
|
# TAG, not row index: rows shift when an egg is reloaded or the
|
|
# seat count changes, and a stale row-keyed stash would restore
|
|
# the WRONG name into whatever row now sits at that index -- a
|
|
# changed roster simply makes tag keys inert instead.
|
|
if tag not in self._seat_defaults:
|
|
self._seat_defaults[tag] = (
|
|
cell.text() if cell is not None else None,
|
|
combo.currentText() if combo is not None else None)
|
|
callsign, mech = info
|
|
else:
|
|
# Empty: restore the configured pilot and forget the stash, so
|
|
# the next player to take this seat re-snapshots. Restore on
|
|
# `is not None`: an operator-BLANKED cell is a real configured
|
|
# value, and skipping it as falsy left the departed name up and
|
|
# then re-captured it as the default (review 2026-07-26).
|
|
callsign, mech = self._seat_defaults.pop(tag, (None, None))
|
|
if callsign is not None and cell is not None and cell.text() != callsign:
|
|
cell.setText(callsign)
|
|
if mech is not None and combo is not None and combo.currentText() != mech:
|
|
combo.setCurrentText(mech)
|
|
continue
|
|
if callsign and cell is not None and cell.text() != callsign:
|
|
cell.setText(callsign)
|
|
if mech and combo is not None and combo.currentText() != mech:
|
|
combo.setCurrentText(mech)
|
|
# A REMOTE operator has no console_proc -- its command channel is the
|
|
# control socket -- so requiring one made LAUNCH (and END MISSION below)
|
|
# permanently dead in remote mode. Accept either channel.
|
|
have_relay = (self.console_proc is not None
|
|
or getattr(self, "remote_link", None) is not None)
|
|
self.launch_btn.setEnabled(
|
|
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
|
|
and not self.monitor.launched
|
|
and have_relay)
|
|
# Re-arm is refused by the relay mid-mission/mid-pair; grey it too so the
|
|
# button beside END MISSION does not invite the press (review 2026-07-26).
|
|
self.rearm_btn.setEnabled(have_relay and not self.monitor.launched)
|
|
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
|
|
# Remote mode has no console_proc but IS a live session -- and the env
|
|
# build below already points BT_RELAY at remote_link.host, so this guard
|
|
# was the only thing making the feature unreachable there.
|
|
if (relay and self.console_proc is None
|
|
and getattr(self, "remote_link", None) is None):
|
|
QMessageBox.warning(
|
|
self, "Launch",
|
|
"The session is not running -- press Start Session first\n"
|
|
"(a local instance dials the relay and would fail without it).")
|
|
return
|
|
if not any(self._row_local(r) for r in range(self.table.rowCount())):
|
|
QMessageBox.warning(
|
|
self, "Launch",
|
|
"No roster row has 'Local' checked -- tick the Local box on\n"
|
|
"YOUR seat (the one you fly on this machine) and try again.")
|
|
return
|
|
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()
|
|
# ONE unified build (2026-07-22): the GLASS platform env is what
|
|
# arms the desktop input stack (PadRIO -> bindings.txt keymap,
|
|
# clickable cockpit). Without it the instance boots the DEV
|
|
# profile and the legacy arrow-drive bridge takes the keyboard
|
|
# (user-reported: "launched with the wrong keymap again").
|
|
env.insert("BT_PLATFORM", "glass")
|
|
# OPERATOR LOAD SPEED (2026-07-22, live finding): the game pins
|
|
# itself to CPU core 0 by default, which on the operator box is
|
|
# shared with the console/relay/desktop -- and the mission load
|
|
# drains frame-paced, so a starved frame rate multiplies load
|
|
# time (testers ~20s, operator 3-5 min). Spread the local
|
|
# instance across the UPPER cores instead.
|
|
if "BT_AFFINITY" not in os.environ:
|
|
env.insert("BT_AFFINITY", "0xF0")
|
|
if self.f_gauges.isChecked():
|
|
env.insert("BT_DEV_GAUGES", "1")
|
|
if self.f_inside.isChecked():
|
|
env.insert("BT_START_INSIDE", "1")
|
|
args = ["-egg", os.path.basename(self.egg_path)
|
|
if os.path.dirname(self.egg_path) == CONTENT
|
|
else self.egg_path]
|
|
if relay:
|
|
# WALK-UP FLOW (2026-07-22): a relay-mode local instance goes
|
|
# through the JOIN-GAME menu exactly like a join.bat player --
|
|
# callsign + mech picked in the menu, seat ASSIGNED by the
|
|
# relay (BT_SELF pinning is gone: it skipped the seat request
|
|
# that carries the choice). Zero-arg launch = the menu; it
|
|
# relaunches itself with -egg OPERATOR.EGG -net <BT_FE_JOINPORT>
|
|
# (distinct per instance -- two -net listeners can't share a
|
|
# port on one box).
|
|
relay_addr = "127.0.0.1"
|
|
if getattr(self, "remote_link", None):
|
|
relay_addr = self.remote_link.host
|
|
env.insert("BT_RELAY", "%s:%d" % (relay_addr,
|
|
self.f_port.value()))
|
|
env.insert("BT_FE_JOIN", "1")
|
|
env.insert("BT_FE_JOINPORT", str(1501 + 100 * r))
|
|
args = []
|
|
else:
|
|
host, _, port = tag.rpartition(":")
|
|
net_port = int(port) - 1
|
|
args += ["-net", str(net_port)]
|
|
# issue #33 forensics: append across the menu->game relaunch
|
|
# chain (each generation used to TRUNCATE the log -- the night-2
|
|
# crash loop left a 2-line file). Fresh file per launch press.
|
|
log_name = "operator_%d.log" % (r + 1)
|
|
try:
|
|
os.remove(os.path.join(CONTENT, log_name))
|
|
except OSError:
|
|
pass
|
|
env.insert("BT_LOG", log_name)
|
|
env.insert("BT_LOG_APPEND", "1")
|
|
proc = QProcess(self)
|
|
proc.setWorkingDirectory(CONTENT)
|
|
proc.setProcessEnvironment(env)
|
|
proc.start(exe, args)
|
|
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"
|
|
|
|
# LAN bat address: auto-discovery (BT_RELAY=auto) proved unreliable on
|
|
# some networks (field report 2026-07-20: a joiner had to hand-set
|
|
# BT_RELAY=<operator-lan-ip>:1500), so pin THIS operator PC's LAN IP.
|
|
# The UDP connect picks the default-route interface without sending a
|
|
# packet; falls back to "auto" only if detection fails.
|
|
def _detect_lan_ip():
|
|
import socket
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(("8.8.8.8", 1))
|
|
ip = s.getsockname()[0]
|
|
except Exception:
|
|
ip = ""
|
|
finally:
|
|
s.close()
|
|
return ip if (ip and not ip.startswith("127.")) else ""
|
|
|
|
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.
|
|
# LOCATION GUARD (field report 2026-07-18: 'cannot find path specified'
|
|
# + dumps): the bat MUST sit in the extracted folder next to content\
|
|
# and build\. Running it from inside the zip (or after moving just the
|
|
# .bat) broke the relative cd with a cryptic error. Check first and
|
|
# give a clear message instead.
|
|
def guarded(header, sets, run, tail):
|
|
return ("@echo off\n" + header +
|
|
"cd /d %~dp0\n"
|
|
# ONE unified build (2026-07-21): every layer is in the one
|
|
# exe; BT_PLATFORM=glass arms the desktop cockpit (clickable
|
|
# buttons / trigger config / bindings.txt keymap).
|
|
"set BT_PLATFORM=glass\n"
|
|
"if not exist \"build\\Release\\btl4.exe\" goto badpath\n"
|
|
"if not exist \"content\\" + egg_name +
|
|
"\" goto badpath\n" +
|
|
sets +
|
|
"cd content\n" + run + tail +
|
|
"exit /b\n"
|
|
":badpath\n"
|
|
"echo.\n"
|
|
"echo ==================================================\n"
|
|
"echo ERROR: this file is not in the right place.\n"
|
|
"echo.\n"
|
|
"echo It must sit in the EXTRACTED game folder, next to\n"
|
|
"echo the 'content' and 'build' folders.\n"
|
|
"echo.\n"
|
|
"echo Fix: right-click the .zip - Extract All, open the\n"
|
|
"echo extracted folder, and run this file from THERE.\n"
|
|
"echo (Do NOT run it from inside the zip.)\n"
|
|
"echo ==================================================\n"
|
|
"echo.\n"
|
|
"pause\n")
|
|
net_tail = ("echo.\n"
|
|
"echo The game has exited. If you did NOT quit on purpose\n"
|
|
"echo (crash, or it never connected), send the operator the\n"
|
|
"echo file content\\join.log so they can see what "
|
|
"happened.\n"
|
|
"echo Otherwise the session may not be up yet -- try "
|
|
"again.\n"
|
|
"pause\n")
|
|
with open(os.path.join(out_dir, "join.bat"), "w", newline="\r\n") as f:
|
|
f.write(guarded(
|
|
"rem BT411 -- join %s's game (internet). Seat/mech assigned\n"
|
|
"rem by the operator. Keep this file next to content\\ + "
|
|
"build\\.\n" % host,
|
|
# BT_START_INSIDE + BT_DEV_GAUGES: the pod is a first-person
|
|
# cockpit with MFD gauges -- match it (field report: remote
|
|
# players booted 3rd-person + no gauges because join.bat set
|
|
# neither; view/gauges are CLIENT flags the relay can't reach).
|
|
"set BT_RELAY=%s:%d\nset BT_LOG=join.log\n"
|
|
"set BT_START_INSIDE=1\nset BT_DEV_GAUGES=1\n"
|
|
% (host, self.f_port.value()),
|
|
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n" % egg_name,
|
|
net_tail))
|
|
lan_ip = _detect_lan_ip()
|
|
lan_relay = ("%s:%d" % (lan_ip, self.f_port.value())) if lan_ip else "auto"
|
|
lan_hdr = (
|
|
("rem BT411 -- join the operator's LAN game.\n"
|
|
"rem BT_RELAY = the operator PC's LAN IP + console port. If that IP\n"
|
|
"rem changes, update it below (operator PC: run `ipconfig`).\n")
|
|
if lan_ip else
|
|
"rem BT411 -- join the operator's LAN game (auto-discovers).\n")
|
|
with open(os.path.join(out_dir, "join_lan.bat"), "w",
|
|
newline="\r\n") as f:
|
|
f.write(guarded(
|
|
lan_hdr +
|
|
"rem Joining over the INTERNET? Use join.bat instead.\n",
|
|
"set BT_RELAY=%s\nset BT_LOG=join.log\n"
|
|
"set BT_START_INSIDE=1\nset BT_DEV_GAUGES=1\n" % lan_relay,
|
|
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n" % egg_name,
|
|
net_tail))
|
|
with open(os.path.join(out_dir, "play_solo.bat"), "w",
|
|
newline="\r\n") as f:
|
|
f.write(guarded(
|
|
"rem BT411 -- single-player play: opens the MISSION MENU (pick "
|
|
"mech, map,\n"
|
|
"rem experience, weather, length; the menu returns after each "
|
|
"mission).\n",
|
|
"set BT_START_INSIDE=1\nset BT_DEV_GAUGES=1\n"
|
|
"set BT_LOG=join.log\nset BT_FE_SOLO=1\n",
|
|
"..\\build\\Release\\btl4.exe\n",
|
|
"echo.\necho The game has exited. If it closed unexpectedly,\n"
|
|
"echo send the operator content\\join.log.\npause\n"))
|
|
self.log.appendPlainText(
|
|
"exported join.bat + join_lan.bat + play_solo.bat to %s "
|
|
"(public host: %s) -- join bats are universal; 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())
|