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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eb618fe9e6
commit
fcedc046cf
+108
-7
@@ -61,6 +61,12 @@ BT_SELF=<their [pilots] entry>.
|
||||
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("<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)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user