diff --git a/context/multiplayer.md b/context/multiplayer.md index b5f14d1..6f0081c 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -354,6 +354,18 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. bitmapindexes. MP_BHMC.EGG template fixed (authentic Aeolus raster preserved bit-exact, Boreas generated). Verified: eggmodel self-test 20/20; ASCII-art decode of app-generated VIPER/MONGOOSE rasters; live 2-node session on the generated egg. +- **MISSION CLOCK (2026-07-18) [T2 live]**: timed missions are authentic and were HALF-implemented + — the egg's `[mission] length=` (600 = the 10-min pod mission) drives the pod-side countdown + (`secondsRemainingInGame`, APP.cpp:652) and the ranking-window display for the final 30s + (DIRECTOR.cpp:113), and the full end chain exists (StopMission id 6 → EndingMission → player + fade → Stop) — but NOTHING fired it: in 1995 the CONSOLE sent StopMission at expiry (mission + length enforcement is console-side). Now reconstructed: the relay arms the clock at RunMission + #2 and sends `Application::StopMissionMessage` (clientID 4, msgID 6, +int exitCode 0) at + length+2s grace; the operator app gets an ⏹ END MISSION button (stdin `stop`, like `launch`). + Pod behavior at stop: authentic fade → mission loop exits → process exits cleanly + (`[boot] RunMissions returned`). Verified live: 40s test mission ended on the clock. So real + sessions now END after the configured length with the score display in the last 30 seconds — + the arcade experience, complete. - **4-POD SESSION VERIFIED (2026-07-18) [T2 live]**: first >2-player run — 4 pods, relay-assigned seats (no BT_SELF), 4-pilot egg (`content/MP4.EGG`, generated via eggmodel incl. 4 callsign bitmaps): 4 distinct seats, 4/4 registered, launch, all 4 "All connections completed", **every diff --git a/tools/btconsole.py b/tools/btconsole.py index 0e19e55..934bfac 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -82,8 +82,10 @@ CONSOLE_HOST_ID = 1 # FirstLegalHostID # the settle delay. Constants from BT_NET_PROBE=1. APPLICATION_CLIENT_ID = 4 # NetworkClient::ApplicationClientID RUN_MISSION_MESSAGE_ID = 5 # Application::RunMissionMessageID +STOP_MISSION_MESSAGE_ID = 6 # Application::StopMissionMessageID (APP.h:383) LAUNCH_SETTLE_SECONDS = 20.0 # egg -> first launch (pods must reach WaitingForLaunch) LAUNCH_STEP_SECONDS = 4.0 # first launch -> second (Launching -> Running) +STOP_GRACE_SECONDS = 2.0 # past the pods' own zero so the ranking shows def run_mission_packet(): @@ -93,6 +95,35 @@ def run_mission_packet(): return pkt +def stop_mission_packet(): + # Application::StopMissionMessage {exitCode=NullExitCodeID} (id 6, + # APPMSG.cpp:21). The 1995 console ENDED the timed mission -- the pods + # only display the countdown (secondsRemainingInGame, APP.cpp:652) and + # pop the ranking window for the final 30s (DIRECTOR.cpp:113); the stop + # itself was always console-side, which is us. + pkt = struct.pack(PKT_FMT_HDR, APPLICATION_CLIENT_ID, 0, CONSOLE_HOST_ID, 0) + pkt += struct.pack(" 0: + print(f"[relay] mission clock: {self.mission_length:.0f}s " + "(StopMission at expiry)", flush=True) + + def _tick_stop(self): + # THE MISSION CLOCK (console-side, as in 1995): send StopMission when + # the egg's [mission] length expires, or on the operator's 'stop' + # command (the End Mission button). + if self.stop_sent or self.launches_sent < 2: + return + expired = (self.mission_length > 0 + and self.mission_started_at is not None + and time.time() >= self.mission_started_at + + self.mission_length + STOP_GRACE_SECONDS) + if not (expired or self.stop_requested): + return + pkt = stop_mission_packet() + for conn in list(self.console_conns): + try: + conn.sock.setblocking(True) + conn.sock.sendall(pkt) + conn.sock.setblocking(False) + except OSError as e: + print(f"[relay] stop send failed to {conn.addr}: {e!r}", + flush=True) + self.stop_sent = True + why = "operator END MISSION" if self.stop_requested else "time expired" + print(f"[relay] StopMission sent to {len(self.console_conns)} pod(s) " + f"({why})", flush=True) # ---------------- game TCP side ---------------- @@ -695,14 +762,16 @@ def relay_main(argv): 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() + # Operator command channel: a daemon thread reads stdin. 'launch' fires + # the mission (manual mode only); 'stop' ends it early (End Mission). + def stdin_reader(): + for line in sys.stdin: + command = line.strip().lower() + if command == "launch": + relay.launch_requested = True + elif command == "stop": + relay.stop_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 8df72ea..dc05884 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -287,6 +287,11 @@ class Operator(QMainWindow): "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) self.launch_local_btn = QPushButton("Launch local instances") self.launch_local_btn.clicked.connect(self._launch_local) self.launch_local_btn.setEnabled(False) @@ -297,6 +302,7 @@ class Operator(QMainWindow): srow.addWidget(self.restart_btn) srow.addSpacing(20) srow.addWidget(self.launch_btn) + srow.addWidget(self.end_btn) srow.addSpacing(20) srow.addWidget(self.launch_local_btn) srow.addWidget(self.stop_games_btn) @@ -524,6 +530,7 @@ class Operator(QMainWindow): # ------------------------------------------------------------- session -- def _start_session(self): + self.end_sent = False problems = self._collect_egg() if problems: QMessageBox.warning(self, "Cannot start", "\n".join(problems)) @@ -569,6 +576,7 @@ class Operator(QMainWindow): self.stop_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.log.appendPlainText("== session stopped ==") @@ -585,8 +593,18 @@ class Operator(QMainWindow): if self.console_proc: self.console_proc.write(b"launch\n") self.launch_btn.setEnabled(False) + self.end_btn.setEnabled(True) self.log.appendPlainText(">> operator LAUNCH sent") + 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.console_proc: + self.console_proc.write(b"stop\n") + self.end_sent = True + self.end_btn.setEnabled(False) + self.log.appendPlainText(">> operator END MISSION sent") + def _console_finished(self): self.log.appendPlainText("== console/relay process exited ==") self.start_btn.setEnabled(True) @@ -612,6 +630,9 @@ class Operator(QMainWindow): % (ST_COLORS[state], tag, state)) if self.monitor.launched: head = "LAUNCHED — mission running" + if (self.console_proc and not self.end_btn.isEnabled() + and not getattr(self, "end_sent", False)): + self.end_btn.setEnabled(True) elif self.monitor.ready: head = "ALL PODS READY — press LAUNCH MISSION" elif self.monitor.relay_mode: