From 5df5c954aea4d15c89725f6e30ecc6f0451c0657 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sat, 18 Jul 2026 11:32:54 -0500 Subject: [PATCH] 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 --- scratchpad/operator_e2e.py | 16 ++++++++-- tools/btconsole.py | 49 +++++++++++++++++++++++++----- tools/btoperator.py | 61 ++++++++++++++++++++++++++++++++++---- 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/scratchpad/operator_e2e.py b/scratchpad/operator_e2e.py index 8f8faea..fef0129 100644 --- a/scratchpad/operator_e2e.py +++ b/scratchpad/operator_e2e.py @@ -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") diff --git a/tools/btconsole.py b/tools/btconsole.py index 0e5bf42..9b738b7 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -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 [--bind ADDR] [--udp-drop PCT]""" + """argv: --relay [--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: diff --git a/tools/btoperator.py b/tools/btoperator.py index faee9cf..162c440 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -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(' %s %s' % (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("%s    %s" % (head, "   ".join(parts))) + self.launch_btn.setEnabled( + self.monitor.ready and not self.monitor.launched + and self.console_proc is not None) if self.monitor.stats: self.stats_label.setText(self.monitor.stats)