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>
This commit is contained in:
arcattack
2026-07-18 11:32:54 -05:00
co-authored by Claude Fable 5
parent 1436d27ac6
commit 5df5c954ae
3 changed files with 110 additions and 16 deletions
+13 -3
View File
@@ -46,16 +46,26 @@ def main():
ticks = {"n": 0}
pressed = {"launch": False}
def step_poll():
ticks["n"] += 1
m = win.monitor
states = dict(m.state) if m else {}
print("[e2e] t=%ds states=%s launched=%s" %
(ticks["n"] * 2, states, m.launched if m else "?"))
print("[e2e] t=%ds states=%s ready=%s launched=%s" %
(ticks["n"] * 2, states, m.ready if m else "?",
m.launched if m else "?"))
# MANUAL GATE: when the relay reports ready, press the Launch button.
if m and m.ready and not m.launched and not pressed["launch"]:
assert win.launch_btn.isEnabled(), "Launch button should be enabled"
print("[e2e] relay READY -> pressing LAUNCH MISSION")
win._launch_mission()
pressed["launch"] = True
if m and m.launched:
result["ok"] = True
status = win.pod_status.text().encode("ascii", "replace").decode()
print("[e2e] LAUNCH observed -- pod status: %s" % status)
print("[e2e] LAUNCH observed (operator-gated=%s) -- pod status: %s"
% (pressed["launch"], status))
finish()
elif ticks["n"] * 2 >= TIMEOUT_S:
print("[e2e] TIMEOUT")
+42 -7
View File
@@ -236,11 +236,15 @@ class RelayConsoleConn:
class Relay:
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct):
def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct,
manual_launch=False):
self.console_port = console_port
self.game_port = console_port + 1
self.bind_addr = bind_addr
self.udp_drop_pct = udp_drop_pct
self.manual_launch = manual_launch
self.launch_requested = False # set by the operator (stdin)
self.eggs_done_at = None
self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read())
self.roster = parse_egg_roster(egg_path)
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
@@ -344,11 +348,18 @@ class Relay:
sock.setblocking(False)
except OSError:
pass
# GLOBAL launch timer: arm/rearm when the LAST pod has its egg.
# GLOBAL launch: arm the timer when the LAST pod has its egg (auto
# mode), or wait for the operator's 'launch' command (manual mode --
# the operator app's Launch button writes it to our stdin).
if self._eggs_out() >= len(self.roster) and self.launches_sent == 0:
self.launch_at = time.time() + LAUNCH_SETTLE_SECONDS
print(f"[relay] all pods have the egg; LAUNCH pair in "
f"{LAUNCH_SETTLE_SECONDS}s", flush=True)
self.eggs_done_at = time.time()
if self.manual_launch:
print("[relay] all pods have the egg; WAITING FOR OPERATOR "
"LAUNCH", flush=True)
else:
self.launch_at = self.eggs_done_at + LAUNCH_SETTLE_SECONDS
print(f"[relay] all pods have the egg; LAUNCH pair in "
f"{LAUNCH_SETTLE_SECONDS}s", flush=True)
def _eggs_out(self):
return sum(1 for c in self.console_conns if c.egg_sent)
@@ -382,6 +393,17 @@ class Relay:
self.console_conns.remove(conn)
def _tick_launch(self):
# Manual mode: the operator's 'launch' arms the timer, still honouring
# the settle window (pods must reach WaitingForLaunch or the handler
# Fail()s -- the same reason the auto timer waits).
if (self.manual_launch and self.launch_requested
and self.launch_at is None and self.eggs_done_at is not None
and self.launches_sent == 0):
self.launch_at = max(time.time(),
self.eggs_done_at + LAUNCH_SETTLE_SECONDS)
wait = max(0.0, self.launch_at - time.time())
print(f"[relay] operator LAUNCH received; firing in {wait:.0f}s",
flush=True)
if self.launch_at is None or self.launches_sent >= 2:
return
if time.time() < self.launch_at:
@@ -594,10 +616,12 @@ class Relay:
def relay_main(argv):
"""argv: --relay <consolePort> <egg-file> [--bind ADDR] [--udp-drop PCT]"""
"""argv: --relay <consolePort> <egg-file> [--bind ADDR] [--udp-drop PCT]
[--manual-launch]"""
args = argv[:]
bind_addr = "0.0.0.0"
udp_drop = 0.0
manual = False
if "--bind" in args:
i = args.index("--bind")
bind_addr = args[i + 1]
@@ -606,15 +630,26 @@ def relay_main(argv):
i = args.index("--udp-drop")
udp_drop = float(args[i + 1])
del args[i:i + 2]
if "--manual-launch" in args:
manual = True
args.remove("--manual-launch")
if len(args) != 2:
print(__doc__)
return 2
console_port = int(args[0])
egg_path = args[1]
relay = Relay(console_port, egg_path, bind_addr, udp_drop)
relay = Relay(console_port, egg_path, bind_addr, udp_drop, manual)
if not relay.roster:
print(f"[relay] ERROR: no [pilots] entries in {egg_path}")
return 2
if manual:
# Operator command channel: a daemon thread reads stdin; the line
# 'launch' fires the mission (the operator app's Launch button).
def stdin_reader():
for line in sys.stdin:
if line.strip().lower() == "launch":
relay.launch_requested = True
threading.Thread(target=stdin_reader, daemon=True).start()
try:
relay.run()
except KeyboardInterrupt:
+55 -6
View File
@@ -59,6 +59,7 @@ class SessionMonitor:
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")
@@ -67,6 +68,7 @@ class SessionMonitor:
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 = ""
@@ -82,6 +84,9 @@ class SessionMonitor:
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)))
@@ -217,8 +222,11 @@ class Operator(QMainWindow):
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")
@@ -271,6 +279,14 @@ class Operator(QMainWindow):
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)
@@ -278,6 +294,9 @@ class Operator(QMainWindow):
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)
@@ -502,6 +521,8 @@ class Operator(QMainWindow):
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]
@@ -515,9 +536,13 @@ class Operator(QMainWindow):
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.log.appendPlainText("== session started (%s) ==" %
("relay" if relay else "mesh"))
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):
@@ -527,10 +552,26 @@ class Operator(QMainWindow):
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)
@@ -554,12 +595,20 @@ class Operator(QMainWindow):
for tag, state in self.monitor.state.items():
parts.append('<span style="color:%s">⬤</span> %s <i>%s</i>'
% (ST_COLORS[state], tag, state))
head = "LAUNCHED — mission running" if self.monitor.launched else (
"eggs delivered: %d/%d" % (self.monitor.eggs,
len(self.monitor.tags))
if self.monitor.relay_mode else "mesh console driving pods")
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)