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) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-23 00:28:56 -05:00
co-authored by Claude Opus 4.8
parent fa935777cc
commit 8368163b04
7 changed files with 178 additions and 11 deletions
+18
View File
@@ -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
+10
View File
@@ -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
+27 -4
View File
@@ -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;
+29
View File
@@ -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
+3
View File
@@ -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,
+59 -7
View File
@@ -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)
+32
View File
@@ -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):