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
+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):