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:
co-authored by
Claude Opus 4.8
parent
fa935777cc
commit
8368163b04
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user