Files
BT411/tools/btoperator.py
T
arcattackandClaude Fable 5 5df5c954ae Operator: manual launch gate + restart-session flow
The operator now holds the lobby: with 'Manual launch' checked (default),
the relay stops at 'all pods have the egg; WAITING FOR OPERATOR LAUNCH'
instead of auto-firing on a 20s timer -- the app shows 'ALL PODS READY --
press LAUNCH MISSION' with the roster lights, and the big green button
fires the mission when the operator is ready (everyone seated, voice chat
confirmed).  A 'Restart session' button cycles the same mission/roster for
the next round (players just re-run their join script).

- btconsole.py --relay --manual-launch: launch armed by the line 'launch'
  on stdin (a daemon reader thread; the app's Launch button writes it via
  QProcess).  The settle window is still honoured relative to egg delivery
  (max(now, eggs_done + 20s)) -- firing RunMission before the pods reach
  WaitingForLaunch Fail()s them, the same hazard the auto timer guards.
  Auto mode (no flag) unchanged -- CLI/test recipes unaffected.
- btoperator.py: Manual-launch checkbox (Network box, default on), LAUNCH
  MISSION button (enabled by the relay's READY line, disabled after fire),
  Restart session button, status headline shows the ready state.
- operator_e2e.py: now exercises the gate -- waits for READY, asserts the
  button enabled, presses it, verifies the operator-gated LAUNCH.

Verified: scripted e2e PASS -- registered -> READY (held) -> operator
launch -> settle honoured -> both pods LAUNCHED (operator-gated=True).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:32:54 -05:00

743 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"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_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", "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)
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]
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> &nbsp;&nbsp; %s"
% (head, " &nbsp; ".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)
for i, p in enumerate(self.egg.pilots()):
# Internet variant: explicit public host.
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"],
egg_name))
# LAN variant: zero-config auto-discovery (BT_RELAY=auto).
lan = os.path.join(out_dir, "join_as_player%d_lan.bat" % (i + 1))
with open(lan, "w", newline="\r\n") as f:
f.write("@echo off\n"
"rem BT411 -- join the LAN game as player %d (%s) --\n"
"rem finds the operator console automatically.\n"
"set BT_RELAY=auto\n"
"set BT_SELF=%s\n"
"cd /d %%~dp0content\n"
"..\\build\\Release\\btl4.exe -egg %s -net 1501\n"
% (i + 1, p.get("vehicle") or "?", p["address"],
egg_name))
self.log.appendPlainText(
"exported %d player script pair(s) (internet + _lan) to %s "
"(public 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())