From 8368163b048f130b3fb8fab2a9adaae34c5a7b70 Mon Sep 17 00:00:00 2001 From: arcattack Date: Thu, 23 Jul 2026 00:28:56 -0500 Subject: [PATCH] Lobby loop: auto-rejoin between rounds + seat reclaim + live mission edits The between-rounds arcade flow (first-playtest-night field request): at mission end every pod exited, seats dropped, and every round required everyone to re-run join.bat with no way to adjust the mission. 1. AUTO-REJOIN: StopMission (operator END / mission clock -- NOT a window close) sets gBTMissionStoppedByConsole; after the matchlog upload btl4main relaunches the pod into the join wait with the same cmdline (BT_CALLSIGN/BT_MECH ride the inherited environment). 2. SEAT RECLAIM: the assigned tag is mirrored file-scope (BTRelaySelfTag) and re-presented as the 3rd SEAT_REQUEST payload field (BT_SEAT_CLAIM); the relay holds a dropped seat for its tag for 90s and a returning player gets their EXACT seat back -- static ordering across rounds, the real-pod model. Non-claim joiners skip reclaim-held seats. 3. LIVE MISSION EDITS: the console gains "Apply mission settings" (enabled while a session runs) writing arena/time/weather/length into the session egg; the relay re-reads the file at round reset and egg release (roster shape locked mid-session). Verified 2-pod 3-round: pods self-relaunched after each clock end; the SECOND-to-rejoin still reclaimed its original seat (ordering held); the between-rounds edit landed ("mission length now 75s"). Known edge noted in the KB: a stale ready flag from a not-yet-exited old conn can satisfy the launch gate mid-rejoin -- operators launch on green dots. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/multiplayer.md | 18 +++++++++++ engine/MUNGA/APP.cpp | 10 ++++++ engine/MUNGA_L4/L4NET.CPP | 31 +++++++++++++++--- game/btl4main.cpp | 29 +++++++++++++++++ players/README.txt | 3 ++ tools/btconsole.py | 66 ++++++++++++++++++++++++++++++++++----- tools/btoperator.py | 32 +++++++++++++++++++ 7 files changed, 178 insertions(+), 11 deletions(-) diff --git a/context/multiplayer.md b/context/multiplayer.md index 3922196..f54da1d 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -449,6 +449,24 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. per-event cost) is an OPEN perf item -- deliberately not attempted pre-8-player-night. Diags: `[loadphase]` markers always on; the full queue dump gates on `BT_LOAD_DIAG=1`; `GeneralEventQueue::DumpBlockers` + the named fallback in `Event::DumpData`. +- **LOBBY LOOP + SEAT RECLAIM (2026-07-23) [T2]**: the between-rounds arcade flow. Field + problem (first playtest night): at mission end every pod EXITED -> seats dropped -> every + round required everyone to re-run join.bat, and the operator could not adjust the mission. + Now: (1) `Application::StopMissionMessageHandler` sets `gBTMissionStoppedByConsole` (operator + END / mission clock only -- a window close still exits for good); btl4main, after the + matchlog upload, relaunches the pod into the join wait (`BTFE_RelaunchSelfAndExit` with the + same cmdline; BT_CALLSIGN/BT_MECH ride the inherited env). (2) **SEAT RECLAIM**: the + assigned tag is mirrored (`BTRelaySelfTag`, L4NET) and re-presented as the 3rd SEAT_REQUEST + payload field (`BT_SEAT_CLAIM`); the relay holds a dropped seat for its tag 90s + (`seat_reclaim`) and returning players get their EXACT seat back -- VERIFIED: the + second-to-rejoin still reclaimed its original seat (static ordering, the real-pod model). + Non-claim joiners skip reclaim-held seats. (3) **BETWEEN-ROUNDS MISSION EDITS**: the GUI's + "Apply mission settings" button (enabled while a session runs) rewrites the session egg's + [mission] params; the relay re-reads the FILE at round reset + egg release + (`_reload_egg_file`; roster shape locked mid-session) -- verified live ("mission length now + 75s" on round 3's reset). KNOWN EDGE [T3]: during the rejoin window a not-yet-exited old + conn's stale `ready` flag can satisfy the all-ready launch gate -- in practice the operator + launches on green dots after rejoin; harden later if it bites. - **READY LIGHT (2026-07-22) [T2, live-verified]**: the roster showed "registered" for a still-loading pod and a ready one alike. When the app ladder reaches WaitingForLaunch (APP.cpp CheckLoad transition) the pod sends ONE empty route **-10** frame on its live relay diff --git a/engine/MUNGA/APP.cpp b/engine/MUNGA/APP.cpp index 22a3e0f..fd33383 100644 --- a/engine/MUNGA/APP.cpp +++ b/engine/MUNGA/APP.cpp @@ -1688,6 +1688,16 @@ void Check(message); Verify(message->messageID == StopMissionMessageID); + // AUTO-REJOIN flag (2026-07-23): a StopMission (operator END or the + // mission clock) is the ARCADE end of a round -- distinct from the player + // closing the window. btl4main relaunches into the join wait when set, + // so the same players flow into the next round without re-running + // join.bat (and reclaim their seats -- see L4NET BT_SEAT_CLAIM). + { + extern int gBTMissionStoppedByConsole; + gBTMissionStoppedByConsole = 1; + } + // //-------------------------------------------------------------------------- // If the application is already stopping then ignore the message diff --git a/engine/MUNGA_L4/L4NET.CPP b/engine/MUNGA_L4/L4NET.CPP index 223496d..508abe4 100644 --- a/engine/MUNGA_L4/L4NET.CPP +++ b/engine/MUNGA_L4/L4NET.CPP @@ -612,6 +612,21 @@ void BTRelayRememberGameSocket(SOCKET game_socket) s_relayGameSocketForReady = game_socket; } +// The assigned seat tag, mirrored file-scope for the between-rounds relaunch +// (the L4NetworkManager -- and its relaySelf -- is gone by then). +static char s_relaySelfTag[64] = ""; + +void BTRelayRememberSelfTag(const char *tag) +{ + strncpy(s_relaySelfTag, tag, sizeof(s_relaySelfTag) - 1); + s_relaySelfTag[sizeof(s_relaySelfTag) - 1] = 0; +} + +const char *BTRelaySelfTag(void) +{ + return s_relaySelfTag; +} + void BTRelayNotifyReady(void) { static int s_ready_sent = 0; @@ -1777,18 +1792,24 @@ Logical L4NetworkManager::RelayRequestSeat() // / BT_MECH; they ride the seat request as {callsign NUL mech NUL} and // the relay writes them into the session egg before it releases eggs. // Empty payload (no env) = the operator's roster defaults, as before. - char seat_payload[64]; + char seat_payload[128]; int seat_payload_length = 0; { const char *callsign = getenv("BT_CALLSIGN"); const char *mech_tag = getenv("BT_MECH"); + // SEAT RECLAIM (2026-07-23): after a round the auto-rejoin carries + // the previous seat tag -- the relay gives the SAME seat back so + // ordering stays static across rounds (the real-pod model). + const char *claim = getenv("BT_SEAT_CLAIM"); if ((callsign != NULL && callsign[0] != '\0') - || (mech_tag != NULL && mech_tag[0] != '\0')) + || (mech_tag != NULL && mech_tag[0] != '\0') + || (claim != NULL && claim[0] != '\0')) { seat_payload_length = _snprintf(seat_payload, - sizeof(seat_payload) - 1, "%s%c%s%c", + sizeof(seat_payload) - 1, "%s%c%s%c%s%c", callsign != NULL ? callsign : "", 0, - mech_tag != NULL ? mech_tag : "", 0); + mech_tag != NULL ? mech_tag : "", 0, + claim != NULL ? claim : "", 0); if (seat_payload_length < 0) seat_payload_length = sizeof(seat_payload) - 1; } @@ -1850,6 +1871,8 @@ Logical L4NetworkManager::RelayRequestSeat() { memcpy(relaySelf, tag, tag_max); relaySelf[tag_max] = '\0'; + extern void BTRelayRememberSelfTag(const char *); + BTRelayRememberSelfTag(relaySelf); DEBUG_STREAM << "[relay] seat ASSIGNED: '" << relaySelf << "'\n" << std::flush; got_seat = True; diff --git a/game/btl4main.cpp b/game/btl4main.cpp index a811dfc..3d25d1b 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -46,6 +46,10 @@ const char* const ProgName = "btl4"; Application *btl4App = NULL; HWND hWnd = NULL; +// Set by Application::StopMissionMessageHandler (APP.cpp): the round ended by +// operator/clock (NOT a window close) -- drives the between-rounds auto-rejoin. +int gBTMissionStoppedByConsole = 0; + // GLASS: resolvers for the per-display windows (declared in l4vb16.h), defined // HERE off the launcher's own app + window pointers. They deliberately do NOT // touch the `application`/`ghWnd` globals: those are duplicate-defined and bind @@ -798,6 +802,31 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine BTRelayUploadMatchLog(); } + // LOBBY LOOP (2026-07-23): a round ended by the operator/clock + // relaunches this pod straight back into the join wait -- the arcade + // between-rounds flow (pods rebooted to attract mode; players stayed + // put). BT_SEAT_CLAIM carries our seat tag so the relay gives us the + // SAME seat back (static ordering, real-pod style); BT_CALLSIGN / + // BT_MECH ride the inherited environment so the choice persists. + // Closing the window still exits for good (flag set only by + // StopMission). + if (gBTMissionStoppedByConsole && getenv("BT_RELAY") != NULL) + { + extern const char *BTRelaySelfTag(void); + const char *seat_tag = BTRelaySelfTag(); + if (seat_tag != NULL && seat_tag[0] != 0) + SetEnvironmentVariableA("BT_SEAT_CLAIM", seat_tag); + char rejoin_arguments[512]; + int m = 0; + for (const char *s2 = lpCmdLine; + s2 != NULL && *s2 && m < (int)sizeof(rejoin_arguments) - 1; ++s2) + rejoin_arguments[m++] = *s2; + rejoin_arguments[m] = 0; + std::cout << "[boot] round ended -- relaunching into the join wait" + << std::endl << std::flush; + BTFE_RelaunchSelfAndExit(rejoin_arguments); // never returns + } + #ifdef BT_GLASS // // Front-end loop (glass step 3): a menu-launched mission returns diff --git a/players/README.txt b/players/README.txt index b3b2e86..0bcae9c 100644 --- a/players/README.txt +++ b/players/README.txt @@ -18,6 +18,9 @@ SINGLE-PLAYER: double-click play_solo.bat -- opens the Your seat is assigned automatically; you'll pilot the mech you picked in the join menu (the operator can override it). +When a round ends your game AUTOMATICALLY rejoins for the next one +(same seat, same mech) -- just wait for the operator's next launch. +Close the window to leave for real. If the game closes unexpectedly, send the operator content\join.log. AFTER A MULTIPLAYER SESSION: the game writes a small match report, diff --git a/tools/btconsole.py b/tools/btconsole.py index c606b74..766d6de 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -314,6 +314,11 @@ class Relay: # meanwhile. self.seat_prefs = {} self.eggs_released = False + # SEAT RECLAIM (2026-07-23): tag -> expiry; set when a seated player + # drops (round end / crash). A rejoin presenting that tag gets the + # SAME seat back (static ordering, real-pod style); other joiners + # skip reclaim-held seats until the grace expires. + self.seat_reclaim = {} # ROUND RESET originals (2026-07-22): trim/prefs FINALIZE the egg for # one round; when every pod leaves, the round resets to these so the # NEXT round's joins hold/rewrite a FRESH egg. (Field report: a @@ -459,9 +464,36 @@ class Relay: except OSError: pass + def _reload_egg_file(self): + """BETWEEN-ROUNDS MISSION EDITS (2026-07-23): re-read the session egg + FILE so operator changes (arena/time/weather/length via the console's + Apply button) take effect next round without a session restart. The + roster SHAPE stays fixed mid-session (seat ids are positional): a + row-count change is refused loudly.""" + try: + new_roster = parse_egg_roster(self.orig_egg_path) + if len(new_roster) != len(self.orig_roster): + print(f"[relay] egg file roster changed " + f"{len(self.orig_roster)}->{len(new_roster)} seats -- " + f"IGNORED (restart the session to resize)", flush=True) + return + self.orig_egg_bytes = egg_wire_bytes( + open(self.orig_egg_path, "rb").read()) + self.egg_bytes = self.orig_egg_bytes + self.egg_path = self.orig_egg_path + new_length = parse_egg_mission_length(self.orig_egg_path) + if new_length != self.mission_length: + print(f"[relay] mission length now {new_length:.0f}s", + flush=True) + self.mission_length = new_length + except OSError as e: + print(f"[relay] egg reload failed ({e!r}) -- keeping the " + f"previous mission", flush=True) + def _release_eggs(self, present=None): if self.eggs_released: return + self._reload_egg_file() # pick up between-rounds edits self.eggs_released = True if present is not None and len(present) < len(self.roster): self._trim_roster(present) @@ -565,6 +597,7 @@ class Relay: return self.eggs_released = False self.egg_path = self.orig_egg_path + self._reload_egg_file() self.egg_bytes = self.orig_egg_bytes self.roster = list(self.orig_roster) self.expected_ids = set(range(FIRST_GAME_HOST_ID, @@ -874,14 +907,30 @@ class Relay: self.seat_reservations = {h: t for h, t in self.seat_reservations.items() if t > now or h in self.seat_beacons} + self.seat_reclaim = {t: e for t, e in self.seat_reclaim.items() + if e > now} + claim_tag = "" + if payload: + fields = payload.split(b"\0") + if len(fields) > 2: + claim_tag = fields[2].decode("ascii", "replace").strip() assigned = None - for i, tag in enumerate(self.roster): - host_id = FIRST_GAME_HOST_ID + i - if (host_id in self.by_host or host_id in self.seat_reservations - or host_id in self.reserved_host_ids): - continue # claimed / racing / operator-local - assigned = (host_id, tag) - break + if claim_tag and claim_tag in self.roster: + host_id = FIRST_GAME_HOST_ID + self.roster.index(claim_tag) + if host_id not in self.by_host and host_id not in self.seat_beacons: + assigned = (host_id, claim_tag) + self.seat_reclaim.pop(claim_tag, None) + print(f"[relay] seat RECLAIMED by returning player " + f"(tag '{claim_tag}')", flush=True) + if assigned is None: + for i, tag in enumerate(self.roster): + host_id = FIRST_GAME_HOST_ID + i + if (host_id in self.by_host or host_id in self.seat_reservations + or host_id in self.reserved_host_ids + or self.seat_reclaim.get(tag, 0) > now): + continue # claimed / racing / held for return + assigned = (host_id, tag) + break if assigned is None: self._send_raw(conn, struct.pack(ENV_FMT_TCP, ROUTE_SEAT_FULL, 0)) @@ -1006,6 +1055,9 @@ class Relay: seat = getattr(conn, "seat_host", None) if seat is not None and self.seat_beacons.get(seat) is conn: del self.seat_beacons[seat] + tag_for_seat = getattr(conn, "seat_tag", None) + if tag_for_seat: # hold for the returning player + self.seat_reclaim[tag_for_seat] = time.time() + 90.0 if seat not in self.by_host: # never claimed: player left self.seat_reservations.pop(seat, None) self.seat_prefs.pop(seat, None) diff --git a/tools/btoperator.py b/tools/btoperator.py index 8329dec..d58e9f8 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -329,6 +329,13 @@ class Operator(QMainWindow): self.restart_btn = QPushButton("↻ Restart session") self.restart_btn.clicked.connect(self._restart_session) self.restart_btn.setEnabled(False) + self.apply_btn = QPushButton("Apply mission settings") + self.apply_btn.setToolTip( + "Write arena/time/weather/length changes into the running " + "session -- takes effect on the NEXT round (players keep their " + "seats between rounds)") + self.apply_btn.clicked.connect(self._apply_mission_settings) + self.apply_btn.setEnabled(False) self.launch_btn = QPushButton("🚀 LAUNCH MISSION") self.launch_btn.setStyleSheet( "font-weight:bold; color:#33cc55; padding:4px 14px;") @@ -352,6 +359,7 @@ class Operator(QMainWindow): srow.addWidget(self.end_btn) srow.addSpacing(20) srow.addWidget(self.launch_local_btn) + srow.addWidget(self.apply_btn) srow.addWidget(self.stop_games_btn) srow.addStretch(1) self.stats_label = QLabel("") @@ -516,6 +524,28 @@ class Operator(QMainWindow): self._renumber_relay_tags() self._status("mode: %s" % self.mode.currentText()) + def _apply_mission_settings(self): + """Write the mission-parameter fields into the running session's egg + file; the relay re-reads it at the next round's egg release, so the + seated players get the new arena/params WITHOUT rejoining.""" + try: + doc = eggmodel.EggDoc.load(self.egg_path) + doc.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()) + doc.save(self.egg_path) + self.log.appendPlainText( + ">> mission settings applied (map=%s time=%s weather=%s " + "length=%s) -- takes effect next round" + % (self.f_map.currentText(), self.f_time.currentText(), + self.f_weather.currentText(), self.f_length.text().strip())) + except Exception as e: + QMessageBox.warning(self, "Apply", + "Could not update the egg:\n%r" % e) + # ------------------------------------------------------------ egg file -- def _load_egg_into_ui(self): @@ -647,6 +677,7 @@ class Operator(QMainWindow): self.restart_btn.setEnabled(True) self.launch_local_btn.setEnabled(True) self.launch_btn.setEnabled(False) + self.apply_btn.setEnabled(True) self.log.appendPlainText("== session started (%s%s) ==" % ("relay" if relay else "mesh", ", manual launch" if relay @@ -665,6 +696,7 @@ class Operator(QMainWindow): self.end_btn.setEnabled(False) self.launch_local_btn.setEnabled(False) self.pod_status.setText("session not running") + self.apply_btn.setEnabled(False) self.log.appendPlainText("== session stopped ==") def _restart_session(self):