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:
co-authored by
Claude Fable 5
parent
1436d27ac6
commit
5df5c954ae
@@ -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
@@ -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
@@ -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> %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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user