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