D1: relay-assigned seats -- players never need a player number

A pod launched with no BT_SELF asks the relay for a seat before joining:
new control frames SEAT_REQUEST (-6) -> relay reserves the lowest roster
seat not claimed or reserved (60s reservation so simultaneous joiners
can't race onto one seat) -> SEAT_ASSIGN (-7, int32 hostID + NUL tag)
becomes relaySelf; everything downstream (egg self-match, HELLO) runs
exactly as if BT_SELF had been set. Roster full -> SEAT_FULL (-8) ->
clean Fail(). A real HELLO pops the reservation; a pod drop frees the
seat. Explicit BT_SELF still claims a specific seat (the operator's
local launches use it).

Client: L4NetworkManager::RelayRequestSeat (throwaway TCP dial to the
relay game port, 10s reply window). Relay: SEAT_REQUEST branch in
_handle_game_frame. Operator exporter collapsed from per-seat
join_as_playerN.bat to ONE universal join.bat (+ join_lan.bat with
BT_RELAY=auto) -- every player gets the same file, first come first
served, the arcade walk-up-to-a-pod model. players/ regenerated.

Verified: 8/8 seat stub tests (distinct seats in roster order, FULL on
exhaustion, claimed+reserved stays FULL, HELLO claim accepted, duplicate
HELLO refused); 2-node localhost e2e with NO BT_SELF on either pod ->
both seated, full ladder, RunMission pair, UDP flowing post-launch;
regression smoke: explicit-BT_SELF relay session clean with zero seat
requests, classic mesh (no BT_RELAY) un-regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-18 12:11:03 -05:00
co-authored by Claude Fable 5
parent efd440c64b
commit f04e8019c2
7 changed files with 195 additions and 36 deletions
+37
View File
@@ -184,6 +184,10 @@ ROUTE_HELLO = -2
ROUTE_PEER_UP = -3
ROUTE_PEER_DOWN = -4
ROUTE_UDP_ACK = -5
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
SEAT_RESERVE_SECONDS = 60.0
HELLO_MAGIC = 0x31525442 # 'BTR1' little-endian
FRAME_CAP = 1600 # NETWORKMANAGER_BUFFER_SIZE (NETWORK.h:89)
FIRST_GAME_HOST_ID = 2 # FirstLegalHostID(1) == the console; pods 2..N+1
@@ -257,6 +261,7 @@ class Relay:
self.console_conns = []
self.game_conns = [] # RelayGameConn (incl. unregistered)
self.by_host = {} # hostID -> RelayGameConn
self.seat_reservations = {} # hostID -> expiry (auto-seats)
self.udp_endpoint = {} # hostID -> (addr, port)
self.udp_sock = None
self.launch_at = None
@@ -472,6 +477,37 @@ class Relay:
return
def _handle_game_frame(self, conn, route, payload):
if conn.host_id is None and route == ROUTE_SEAT_REQUEST:
# SERVER-ASSIGNED SEATS: hand out the lowest roster seat that is
# neither claimed (by_host) nor reserved; reserve it briefly so
# two simultaneous joiners can't race onto the same seat. The
# requesting connection closes after the reply; the pod re-dials
# and claims the seat with a normal HELLO.
now = time.time()
self.seat_reservations = {h: t for h, t in
self.seat_reservations.items() if t > now}
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:
continue
assigned = (host_id, tag)
break
if assigned is None:
self._send_raw(conn, struct.pack(ENV_FMT_TCP,
ROUTE_SEAT_FULL, 0))
print(f"[relay] {conn.name()} seat request: ROSTER FULL",
flush=True)
return
host_id, tag = assigned
self.seat_reservations[host_id] = now + SEAT_RESERVE_SECONDS
payload_out = struct.pack("<i", host_id) + tag.encode() + b"\0"
self._send_raw(conn, struct.pack(ENV_FMT_TCP, ROUTE_SEAT_ASSIGN,
len(payload_out)) + payload_out)
print(f"[relay] {conn.name()} seat request -> assigned host "
f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)",
flush=True)
return
if conn.host_id is None:
# First frame MUST be a valid HELLO.
if route != ROUTE_HELLO or len(payload) < 8:
@@ -491,6 +527,7 @@ class Relay:
return
conn.host_id = host_id
self.by_host[host_id] = conn
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)
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.