From fcedc046cfeca7fb023c83e70eecf84018595de0 Mon Sep 17 00:00:00 2001 From: arcattack Date: Wed, 22 Jul 2026 13:58:51 -0500 Subject: [PATCH] Walk-up callsign/mech request: join menu -> relay -> the session egg The 1995 front-desk conversation, over the internet. join.bat/ join_lan.bat now open a JOIN-GAME menu (the FE in BT_FE_JOIN trim: CALLSIGN edit + the 18-mech list + JOIN); the choice relaunches into the normal join with BT_CALLSIGN/BT_MECH env, rides the relay SEAT_REQUEST as {callsign NUL mech NUL} (empty payload = roster defaults, wire- compatible), and the relay validates the mech tag, HOLDS egg delivery until every pod's console pad is connected, rewrites the egg via eggmodel (vehicle= + rasterized callsign bitmaps, graceful no-PySide6 fallback) and streams it to all pads -- so every player's egg copy carries every player's callsign. The hold is gated on CONSOLE-PAD count, not game-side registration: a pod HELLOs only after parsing the egg's roster, so a registration gate deadlocks (hit live in the first run; pods animate in WAITING FOR MISSION ASSIGNMENT during the hold). Verified 2-node e2e (env-injected requests): thor/vulture requested over roster defaults bhk1/ava1 -> relay held 1/2, released at 2/2, rewrote both seats (relay log), the delivered egg carries vehicle=thor/vulture + name=VIPER/MONGOOSE, both pods launched and built [cyl] tables for both requested mechs. Join-menu layout screenshot-verified; the menu's click-through relaunch awaits live human verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/multiplayer.md | 16 ++++++ engine/MUNGA_L4/L4NET.CPP | 27 ++++++++- game/btl4main.cpp | 10 ++++ game/glass/btl4fe.cpp | 74 ++++++++++++++++++++---- game/glass/btl4fe.hpp | 3 + players/README.txt | 9 ++- players/join.bat | 5 +- players/join_lan.bat | 5 +- tools/btconsole.py | 115 +++++++++++++++++++++++++++++++++++--- 9 files changed, 239 insertions(+), 25 deletions(-) diff --git a/context/multiplayer.md b/context/multiplayer.md index 1359dc2..5420dda 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -378,6 +378,22 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`. SEAT_REQUEST branch (btconsole.py). Verified: 8/8 stub tests (distinct seats, FULL on exhaustion, claim clears reserve, duplicate-HELLO refused) + 2-node localhost e2e with NO BT_SELF on either pod → both seated, full ladder, launch, UDP flowing. +- **WALK-UP CALLSIGN/MECH REQUEST (2026-07-22) [T2]**: the 1995 front-desk conversation, over + the wire. join.bat/join_lan.bat set `BT_FE_JOIN=1` and launch ZERO-ARG -> the FE menu in JOIN + trim (btl4fe.cpp `FeJoinOnly()`: CALLSIGN edit + the 18-mech list + JOIN; screenshot-verified) + -> `BTFeLaunchJoinRelay` relaunches with the universal join args + `BT_CALLSIGN`/`BT_MECH` env + -> `RelayRequestSeat` sends them as the SEAT_REQUEST payload `{callsign NUL mech NUL}` (empty + = roster defaults, wire-compatible) -> the relay validates the mech tag (the 18 certified + codes), stores per-seat prefs, and **HOLDS egg delivery until every pod's console pad is + connected** -- NOT until game-side registration: a pod HELLOs only after parsing the egg + roster, so that gate DEADLOCKS (hit live, fixed; pods animate in WAITING FOR MISSION + ASSIGNMENT during the hold) -- then rewrites the egg via eggmodel (vehicle= + rasterized + callsign bitmaps; PySide6 absent -> vehicles still apply, bitmap fallback logged) and streams + it to all pads. VERIFIED 2-node e2e: requested thor/vulture over roster defaults bhk1/ava1 -> + relay held 1/2, released 2/2, rewrote both seats, LAST.EGG carries vehicle=thor/vulture + + name=VIPER/MONGOOSE, both pods launched with `[cyl] table 'thor'/'vulture'` live. The FE + menu->relaunch path itself: awaiting live human verification (env-injected path is what the + e2e run proves). BT_SELF pods skip the request (defaults). - **PATIENT WALK-UP (2026-07-18) [T2]**: a pod dialed at a relay that isn't up yet no longer aborts (release `Fail()` is a bare `abort()` = looked like a "crash", field report). Both the seat request (`RelayRequestSeat`) and LAN discovery (`BT_RELAY=auto`) now RETRY (2s dials, 30 diff --git a/engine/MUNGA_L4/L4NET.CPP b/engine/MUNGA_L4/L4NET.CPP index 00612f2..1fa5545 100644 --- a/engine/MUNGA_L4/L4NET.CPP +++ b/engine/MUNGA_L4/L4NET.CPP @@ -1719,10 +1719,33 @@ Logical L4NetworkManager::RelayRequestSeat() } Logical got_seat = False; Logical roster_full = False; + // CALLSIGN/MECH REQUEST (2026-07-22, the walk-up desk over the wire): + // the join menu (BT_FE_JOIN) stashes the player's picks in BT_CALLSIGN + // / 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]; + int seat_payload_length = 0; + { + const char *callsign = getenv("BT_CALLSIGN"); + const char *mech_tag = getenv("BT_MECH"); + if ((callsign != NULL && callsign[0] != '\0') + || (mech_tag != NULL && mech_tag[0] != '\0')) + { + seat_payload_length = _snprintf(seat_payload, + sizeof(seat_payload) - 1, "%s%c%s%c", + callsign != NULL ? callsign : "", 0, + mech_tag != NULL ? mech_tag : "", 0); + if (seat_payload_length < 0) + seat_payload_length = sizeof(seat_payload) - 1; + } + } RelayTcpEnvelope request; request.route = RELAY_ROUTE_SEAT_REQUEST; - request.length = 0; - if (RelaySendAll(seat_socket, (const char *)&request, sizeof(request))) + request.length = (unsigned int)seat_payload_length; + if (RelaySendAll(seat_socket, (const char *)&request, sizeof(request)) + && (seat_payload_length == 0 + || RelaySendAll(seat_socket, seat_payload, seat_payload_length))) { char reply[sizeof(RelayTcpEnvelope) + 80]; int have = 0; diff --git a/game/btl4main.cpp b/game/btl4main.cpp index 4798b03..cd962d5 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -457,6 +457,16 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine SetEnvironmentVariableA("BT_FE_EGG", NULL); sprintf(fe_arguments, "-net %d -platform glass", fe_spec.consolePort); break; + case BTFeLaunchJoinRelay: + // BT_FE_JOIN (join.bat): the callsign/mech request rides env + // into the relaunched pod; L4NET's SEAT_REQUEST carries it to + // the relay, which writes it into the session egg. Args = the + // universal join contract (join.bat's own OPERATOR.EGG/1501). + SetEnvironmentVariableA("BT_FE_EGG", NULL); + SetEnvironmentVariableA("BT_CALLSIGN", fe_spec.joinCallsign); + SetEnvironmentVariableA("BT_MECH", fe_spec.joinVehicle); + strcpy(fe_arguments, "-egg OPERATOR.EGG -net 1501 -platform glass"); + break; #ifdef BT_STEAM case BTFeLaunchJoinSteam: SetEnvironmentVariableA("BT_FE_EGG", NULL); diff --git a/game/glass/btl4fe.cpp b/game/glass/btl4fe.cpp index 290a306..5aeafb5 100644 --- a/game/glass/btl4fe.cpp +++ b/game/glass/btl4fe.cpp @@ -588,6 +588,25 @@ static int return s; } +// +// BT_FE_JOIN=1 (join.bat / join_lan.bat): the JOIN-GAME menu -- the walk-up +// desk conversation of the 1995 centers, over the internet. Only CALLSIGN + +// MECH are the player's to pick (the operator owns the mission + the rest of +// the loadout); the choice rides the relay SEAT_REQUEST and the relay writes +// it into the session egg (btconsole.py). +// +static int + FeJoinOnly() +{ + static int s = -1; + if (s < 0) + { + const char *e = getenv("BT_FE_JOIN"); + s = (e != NULL && *e != '0') ? 1 : 0; + } + return s; +} + static int GroupSize(int group) { @@ -719,6 +738,15 @@ static void { menu.itemCount = 0; int y; + if (FeJoinOnly()) + { + // JOIN trim: the mech list + the JOIN button; callsign is the edit + // control (repositioned in BTFrontEnd_Run). Everything else is the + // operator's call. + y = MenuTopY; AddGroup(GroupVehicle, MenuCol2X, &y); + AddButton(GroupLaunch, MenuCol5X, MenuClientH - 190, 220, 52); + return; + } // col1: the mission y = MenuTopY; AddGroup(GroupMap, MenuCol1X, &y); AddGroup(GroupScenario, MenuCol1X, &y); @@ -843,8 +871,10 @@ static void HFONT old_font = (HFONT)SelectObject(dc, menu.titleFont); RECT title_rect = { MenuCol1X, 14, client.right - 24, 54 }; - DrawMenuText(dc, "BATTLETECH -- MISSION CONSOLE", &title_rect, - kGreenBright, DT_LEFT | DT_TOP | DT_SINGLELINE); + DrawMenuText(dc, + FeJoinOnly() ? "BATTLETECH -- JOIN GAME" + : "BATTLETECH -- MISSION CONSOLE", + &title_rect, kGreenBright, DT_LEFT | DT_TOP | DT_SINGLELINE); SelectObject(dc, menu.textFont); @@ -896,12 +926,15 @@ static void // Edit labels (the controls themselves are child windows in col5). // RECT label = { MenuCol5X, MenuNameLabelY, MenuCol5X + 240, MenuNameLabelY + MenuRowH }; - DrawMenuText(dc, "PILOT NAME", &label, kGreenDim, - DT_LEFT | DT_VCENTER | DT_SINGLELINE); - label.top = MenuPeersLabelY; - label.bottom = label.top + MenuRowH; - DrawMenuText(dc, "LAN PEERS (host: ip:port,...)", &label, kGreenDim, - DT_LEFT | DT_VCENTER | DT_SINGLELINE); + DrawMenuText(dc, FeJoinOnly() ? "CALLSIGN" : "PILOT NAME", &label, + kGreenDim, DT_LEFT | DT_VCENTER | DT_SINGLELINE); + if (!FeJoinOnly()) + { + label.top = MenuPeersLabelY; + label.bottom = label.top + MenuRowH; + DrawMenuText(dc, "LAN PEERS (host: ip:port,...)", &label, kGreenDim, + DT_LEFT | DT_VCENTER | DT_SINGLELINE); + } // // Buttons (framed, RP412 style). @@ -911,7 +944,7 @@ static void { const MenuItem &item = menu.items[i]; const char *text = - (item.group == GroupLaunch) ? "L A U N C H" : + (item.group == GroupLaunch) ? (FeJoinOnly() ? "J O I N" : "L A U N C H") : (item.group == GroupSteamHost) ? "HOST STEAM LOBBY" : (item.group == GroupSteamJoin) ? "JOIN STEAM LOBBY" : NULL; if (text == NULL) @@ -926,7 +959,9 @@ static void DeleteObject(frame); RECT help = { MenuCol1X, client.bottom - 30, client.right - 24, client.bottom - 6 }; - DrawMenuText(dc, "click to select ENTER = launch ESC = quit", + DrawMenuText(dc, + FeJoinOnly() ? "click to select ENTER = join ESC = quit" + : "click to select ENTER = launch ESC = quit", &help, kGreenDim, DT_LEFT | DT_TOP | DT_SINGLELINE); SelectObject(dc, old_font); @@ -1037,7 +1072,8 @@ int DWORD style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; AdjustWindowRect(&frame_rect, style, FALSE); menu.window = CreateWindowW( - L"BTFrontEndWnd", L"BattleTech - Mission Console", + L"BTFrontEndWnd", + FeJoinOnly() ? L"BattleTech - Join Game" : L"BattleTech - Mission Console", style, 100, 80, frame_rect.right - frame_rect.left, frame_rect.bottom - frame_rect.top, NULL, NULL, GetModuleHandleW(NULL), NULL); @@ -1067,6 +1103,10 @@ int SendMessageW(menu.peersEdit, WM_SETFONT, (WPARAM)menu.textFont, TRUE); SetWindowTextA(menu.nameEdit, pilot_name); SetWindowTextA(menu.peersEdit, peers); + if (FeJoinOnly()) + { + ShowWindow(menu.peersEdit, SW_HIDE); // operator's concern, not the joiner's + } ShowWindow(menu.window, SW_SHOW); SetForegroundWindow(menu.window); @@ -1104,6 +1144,18 @@ int // // Resolve the launch spec + write the egg. // + if (FeJoinOnly()) + { + // JOIN GAME: no egg, no port math -- the relay owns the session. The + // callsign + mech request rides the SEAT_REQUEST payload (L4NET reads + // BT_CALLSIGN / BT_MECH, set by WinMain from this spec). + spec->mode = BTFeLaunchJoinRelay; + strncpy(spec->joinCallsign, pilot_name[0] ? pilot_name : "Pilot", + sizeof(spec->joinCallsign) - 1); + strncpy(spec->joinVehicle, kVehicles[menu.selection[GroupVehicle]].key, + sizeof(spec->joinVehicle) - 1); + return 0; + } int console_port = kPorts[menu.selection[GroupPort]]; const RoleEntry &scenario = kScenarios[menu.selection[GroupScenario]]; BTFeMission mission; diff --git a/game/glass/btl4fe.hpp b/game/glass/btl4fe.hpp index e037b42..5dcc689 100644 --- a/game/glass/btl4fe.hpp +++ b/game/glass/btl4fe.hpp @@ -55,6 +55,7 @@ enum BTFeLaunchMode BTFeLaunchRawSolo, // plain -egg endless solo (renderer work) BTFeLaunchHostLan, // marshal feeds self + remote pods BTFeLaunchJoinLan, // plain -net pod; a remote host's marshal feeds us + BTFeLaunchJoinRelay, // BT_FE_JOIN: relay join with callsign/mech request BTFeLaunchHostSteam, // (BT_STEAM) lobby host: marshal over the Steam wire BTFeLaunchJoinSteam // (BT_STEAM) lobby member: -net pod on the Steam wire }; @@ -68,6 +69,8 @@ struct BTFeLaunchSpec int missionSeconds; char steamMyToken[48]; // (BT_STEAM) my roster token ip char steamMap[512]; // (BT_STEAM) ip=steamid64;... + char joinCallsign[32]; // (BT_FE_JOIN) requested callsign + char joinVehicle[16]; // (BT_FE_JOIN) requested mech tag }; // diff --git a/players/README.txt b/players/README.txt index 0fa6c6a..b3b2e86 100644 --- a/players/README.txt +++ b/players/README.txt @@ -6,15 +6,18 @@ Do NOT run anything from inside the zip. The .bat files must sit next to the 'content' and 'build' folders. TO JOIN AN INTERNET GAME: double-click join.bat - (waits for the operator's session if it's not up yet) -TO JOIN A LAN GAME: double-click join_lan.bat (operator's LAN) + -- pick your CALLSIGN and MECH, click JOIN (your choice is sent to + the operator; waits for the session if it's not up yet) +TO JOIN A LAN GAME: double-click join_lan.bat (operator's LAN; + same callsign/mech menu) SINGLE-PLAYER: double-click play_solo.bat -- opens the mission menu: pick your mech (all 18), map, experience level, colours, weather and mission length, then launch {STEAM}STEAM INTERNET PLAY: double-click play_steam.bat (EXPERIMENTAL -- {STEAM} needs the Steam client running; host or join from the menu) -Your seat and mech are assigned automatically by the operator. +Your seat is assigned automatically; you'll pilot the mech you picked +in the join menu (the operator can override it). 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/players/join.bat b/players/join.bat index 47578cf..9147767 100644 --- a/players/join.bat +++ b/players/join.bat @@ -13,7 +13,10 @@ set BT_LOG=join.log set BT_START_INSIDE=1 set BT_DEV_GAUGES=1 cd content -..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501 +rem JOIN-GAME menu (2026-07-22): pick your CALLSIGN + MECH, click JOIN -- +rem your choice is sent to the operator and lands in the mission roster. +set BT_FE_JOIN=1 +..\build\Release\btl4.exe echo. echo The game has exited. If you did NOT quit on purpose echo (crash, or it never connected), send the operator the diff --git a/players/join_lan.bat b/players/join_lan.bat index a78f96b..9a5e1e7 100644 --- a/players/join_lan.bat +++ b/players/join_lan.bat @@ -16,7 +16,10 @@ set BT_LOG=join.log set BT_START_INSIDE=1 set BT_DEV_GAUGES=1 cd content -..\build\Release\btl4.exe -egg OPERATOR.EGG -net 1501 +rem JOIN-GAME menu (2026-07-22): pick your CALLSIGN + MECH, click JOIN -- +rem your choice is sent to the operator and lands in the mission roster. +set BT_FE_JOIN=1 +..\build\Release\btl4.exe echo. echo The game has exited. If you did NOT quit on purpose echo (crash, or it never connected), send the operator the diff --git a/tools/btconsole.py b/tools/btconsole.py index 62fb42f..b8b9f24 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -61,6 +61,12 @@ BT_SELF=. import os import random import selectors +# player callsign/mech seat requests mutate the session egg via eggmodel; +# import guarded so a stripped install still relays with roster defaults. +try: + import eggmodel +except ImportError: + eggmodel = None import socket import struct import sys @@ -220,6 +226,10 @@ ROUTE_SEAT_REQUEST = -6 # client->relay: assign me a free roster seat ROUTE_SEAT_ASSIGN = -7 # relay->client: int32 hostID + NUL-terminated tag ROUTE_SEAT_FULL = -8 # relay->client: no free seats ROUTE_MATCHLOG = -9 # client->relay: matchlog upload (filename NUL + bytes) +# the 18 certified mech tags (mirror of the FE kVehicles catalog) +VEHICLE_TAGS = {"blkhawk", "loki", "bhk1", "madcat", "thor", "owens", + "own1", "thr1", "lok1", "mad1", "avatar", "ava1", + "sunder", "snd1", "vulture", "vul1", "lok2", "mad2"} MATCHLOG_CAP = 8 * 1024 * 1024 # matchlog upload frame cap (client caps at 8MB) SEAT_RESERVE_SECONDS = 60.0 HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian @@ -294,6 +304,15 @@ class Relay: self.stop_sent = False self.eggs_done_at = None self.egg_bytes = egg_wire_bytes(open(egg_path, "rb").read()) + self.egg_path = egg_path + # WALK-UP SEAT PREFS (2026-07-22): callsign/mech requested in the + # SEAT_REQUEST payload, keyed by assigned host id. Eggs are HELD + # until the roster completes so every pod's egg carries every + # player's callsign (an early pod's egg would otherwise miss late + # joiners' labels); pods animate in WAITING FOR MISSION ASSIGNMENT + # meanwhile. + self.seat_prefs = {} + self.eggs_released = False self.roster = parse_egg_roster(egg_path) self.expected_ids = set(range(FIRST_GAME_HOST_ID, FIRST_GAME_HOST_ID + len(self.roster))) @@ -389,13 +408,27 @@ class Relay: conn = RelayConsoleConn(sock, addr) self.console_conns.append(conn) self.sel.register(sock, selectors.EVENT_READ, ("console", conn)) - # Stream the egg immediately (blocking sendall is fine: the pod's console - # pad drains fast and the egg is a few KB). - sock.setblocking(True) + # Egg delivery: HELD until every pod's console pad is connected + # (walk-up seat prefs must be in every pod's egg copy). NOT gated on + # game-side registration -- a pod HELLOs only after it parses the + # egg's roster, so that gate would deadlock. Seat requests precede + # the console pad, so by the Nth pad every pref is already in. + if self.eggs_released: + self._send_egg(conn) + elif len(self.console_conns) >= len(self.roster): + self._release_eggs() + else: + print(f"[relay] console conn {addr[0]}:{addr[1]}: egg HELD " + f"({len(self.console_conns)}/{len(self.roster)} pods " + f"present)", flush=True) + + def _send_egg(self, conn): + addr = conn.sock.getpeername() + conn.sock.setblocking(True) try: n = 0 for pkt in egg_packets(self.egg_bytes): - sock.sendall(pkt) + conn.sock.sendall(pkt) n += 1 conn.egg_sent = True print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent " @@ -407,10 +440,60 @@ class Relay: return finally: try: - sock.setblocking(False) + conn.sock.setblocking(False) except OSError: pass + def _release_eggs(self): + if self.eggs_released: + return + self.eggs_released = True + if self.seat_prefs: + self._apply_seat_prefs() + for conn in list(self.console_conns): + if not conn.egg_sent: + self._send_egg(conn) + + def _apply_seat_prefs(self): + # Rewrite vehicle= + the callsign system from the walk-up prefs. + # eggmodel's rasterizer needs PySide6 (present when launched from the + # operator app); without it the vehicle still applies and the + # callsign falls back to the text name= (labels keep the roster + # default bitmaps). + if eggmodel is None: + print("[relay] eggmodel unavailable -- seat prefs NOT applied", + flush=True) + return + try: + doc = eggmodel.EggDoc.load(self.egg_path) + pilots = doc.pilots() + names = [p.get("name") or p["address"] for p in pilots] + for host_id, (callsign, mech) in sorted(self.seat_prefs.items()): + i = host_id - FIRST_GAME_HOST_ID + if not (0 <= i < len(pilots)): + continue + if mech: + doc.set_kv(pilots[i]["address"], "vehicle", mech) + if callsign: + names[i] = callsign + try: + doc.set_callsigns(eggmodel.make_callsigns(names)) + except Exception as e: + print(f"[relay] callsign rasterizer unavailable ({e!r}) -- " + f"vehicles applied, callsign bitmaps unchanged", + flush=True) + self.egg_bytes = egg_wire_bytes( + doc.emit().encode("latin-1", "replace")) + for host_id, (callsign, mech) in sorted(self.seat_prefs.items()): + i = host_id - FIRST_GAME_HOST_ID + if 0 <= i < len(pilots): + print(f"[relay] seat {host_id} egg: " + f"vehicle={mech or pilots[i].get('vehicle')} " + f"callsign={callsign or names[i]!r}", flush=True) + except Exception as e: + print(f"[relay] seat-pref egg rewrite FAILED ({e!r}) -- " + f"serving the original egg", flush=True) + def _pods_ready(self): """REAL pods = console connections that ACKED the egg (scanners never speak the protocol, so they can't hold a roster slot).""" @@ -665,12 +748,28 @@ class Relay: return host_id, tag = assigned self.seat_reservations[host_id] = now + SEAT_RESERVE_SECONDS + # WALK-UP PREFS: optional payload {callsign NUL mech NUL} + pref_note = "" + if payload: + parts = payload.split(b"\0") + callsign = parts[0].decode("ascii", "replace").strip()[:15] + mech = (parts[1].decode("ascii", "replace").strip().lower() + if len(parts) > 1 else "") + callsign = "".join(c for c in callsign + if c.isalnum() or c in " -_.") + if mech and mech not in VEHICLE_TAGS: + print(f"[relay] {conn.name()} requested unknown mech " + f"{mech!r} -- keeping the roster default", flush=True) + mech = "" + if callsign or mech: + self.seat_prefs[host_id] = (callsign, mech) + pref_note = f" callsign={callsign!r} mech={mech or '(default)'}" payload_out = struct.pack(" assigned host " - f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)", - flush=True) + f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)" + f"{pref_note}", flush=True) return if conn.host_id is None: # First frame MUST be a valid HELLO. @@ -694,6 +793,8 @@ class Relay: self.seat_reservations.pop(host_id, None) # claim clears reserve print(f"[relay] {conn.name()} REGISTERED " f"({len(self.by_host)}/{len(self.roster)})", flush=True) + if len(self.by_host) >= len(self.roster): + self._release_eggs() # fallback; normally already fired # PEER_UP exchange: newcomer learns everyone; everyone learns newcomer. for other_id in sorted(self.by_host): if other_id != host_id: