Launch with whoever connects + live roster display in the console
The operator no longer has to match the roster row count to the exact player count (or watch the "empty seats will STALL" warning). Two mechanisms: 1. PRESENCE BEACONS: the pod keeps its seat-request TCP connection open for the process lifetime; the relay reads a live beacon as "seated" and a pre-claim FIN frees the seat (no ghost seats -- a ghost stalls every pod at the connection gate). Reservation expiry spares beaconed seats. 2. TRIM-ON-LAUNCH (manual/GUI mode): operator LAUNCH before the roster fills shrinks the session to the seats present -- egg [pilots]/pages trimmed, seat ids REMAPPED by position (pods re-derive their host id from their tag's roster position, so walk-up prefs and beacons re-key consistently), roster/expected_ids shrink, eggs release, the launch pair fires as ACKs land. GUI: roster rows now update LIVE from the walk-up requests (SEAT n PRESENT/FREED lines -> callsign/mech cells + a blue "seated" status light), and LAUNCH MISSION enables from the first seated player with a "starts with whoever is here" banner. Verified 2-node: 2-of-4 trim ran the mission with the requested mechs; and the mid-roster GAP case -- three joined, the middle player quit pre-launch (beacon FIN freed the seat), trim remapped seat 4->3 with the survivor's callsign/mech following, both survivors registered under the remapped ids and ran the mission. Also: stdin reader guarded against sys.stdin=None spawn shapes (the GUI QProcess pipe is the supported operator channel). 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
95735db5ae
commit
10eee05b20
@@ -394,6 +394,24 @@ phase). Plan: `~/.claude/plans/partitioned-snuggling-piglet.md`.
|
||||
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).
|
||||
- **LAUNCH WITH WHOEVER CONNECTS (2026-07-22) [T2]**: the operator no longer sizes the roster
|
||||
to the exact player count. Mechanism: (1) **presence beacons** -- the pod KEEPS its
|
||||
seat-request TCP connection open for the process lifetime (L4NET `s_seatBeacon`); a live
|
||||
beacon = a seated player, its FIN before game-side claim FREES the seat (no ghost seats --
|
||||
a ghost stalls every pod at the connection gate). Reservation expiry spares beaconed seats.
|
||||
(2) **trim-on-launch** (manual/GUI mode): operator LAUNCH before the roster fills shrinks the
|
||||
session to the present seats -- egg [pilots] + pages trimmed (`_relay_trimmed.egg`), seat ids
|
||||
REMAP by position (a pod re-derives its host id from its TAG's position in the received egg,
|
||||
so prefs/beacons re-key and every pod stays consistent), roster/expected_ids shrink, eggs
|
||||
release, launch fires as the ACKs land. The GUI: roster rows show live walk-up
|
||||
callsign/mech requests (SEAT n PRESENT/FREED lines -> table cells + a blue "seated" light),
|
||||
and LAUNCH enables from the FIRST seated player. Operator flow: set ~8 default rows, let
|
||||
players trickle in, launch whenever. VERIFIED 2-node: 2-of-4 trim -> mission ran with the
|
||||
requested mechs; AND the mid-roster GAP case -- 3 joined, the MIDDLE one quit pre-launch
|
||||
(beacon FIN freed the seat), trim remapped seat 4->3 (the survivor's prefs followed), both
|
||||
survivors registered under remapped ids and ran the mission. Auto (CLI) mode keeps the
|
||||
full-roster behavior. Caveat: `sys.stdin` is None under some spawn shapes (pyenv .bat shim
|
||||
pipelines) -- the stdin reader guards it; the GUI's QProcess pipe is the supported channel.
|
||||
- **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
|
||||
|
||||
@@ -1803,14 +1803,23 @@ Logical L4NetworkManager::RelayRequestSeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
closesocket(seat_socket);
|
||||
if (got_seat)
|
||||
{
|
||||
// PRESENCE BEACON (2026-07-22, "launch with whoever connects"):
|
||||
// KEEP the seat socket open for the process lifetime. The relay
|
||||
// reads a live beacon as "this player is seated": the operator
|
||||
// can LAUNCH with the seats present, and if this pod closes
|
||||
// before launch the FIN frees the seat instead of ghosting it
|
||||
// (a ghost seat stalls every other pod at the connection gate).
|
||||
// The OS closes it at exit; the relay ignores its silence.
|
||||
static SOCKET s_seatBeacon = INVALID_SOCKET;
|
||||
s_seatBeacon = seat_socket;
|
||||
char joined[128];
|
||||
sprintf(joined, "\nSeat assigned (%s) -- joining!\n", relaySelf);
|
||||
BTRelayWaitStatus(joined);
|
||||
return True;
|
||||
}
|
||||
closesocket(seat_socket);
|
||||
if (roster_full && !full_shown)
|
||||
{
|
||||
BTRelayWaitStatus(
|
||||
|
||||
+88
-2
@@ -313,6 +313,12 @@ class Relay:
|
||||
# meanwhile.
|
||||
self.seat_prefs = {}
|
||||
self.eggs_released = False
|
||||
# PRESENCE BEACONS (2026-07-22, "launch with whoever connects"): the
|
||||
# pod KEEPS its seat-request TCP connection open until it exits; a
|
||||
# live beacon = a seated player. A beacon dropping before the seat
|
||||
# was claimed (game-side HELLO) FREES the seat. The operator's
|
||||
# LAUNCH trims the roster to the seats present.
|
||||
self.seat_beacons = {}
|
||||
self.roster = parse_egg_roster(egg_path)
|
||||
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
|
||||
FIRST_GAME_HOST_ID + len(self.roster)))
|
||||
@@ -444,16 +450,61 @@ class Relay:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _release_eggs(self):
|
||||
def _release_eggs(self, present=None):
|
||||
if self.eggs_released:
|
||||
return
|
||||
self.eggs_released = True
|
||||
if present is not None and len(present) < len(self.roster):
|
||||
self._trim_roster(present)
|
||||
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 _trim_roster(self, present):
|
||||
"""LAUNCH WITH WHOEVER CONNECTS: shrink the session to the seats
|
||||
with live beacons (or claims). Positions shift, so seat ids REMAP --
|
||||
a pod re-derives its host id from its TAG's position in the received
|
||||
egg, so prefs/beacons re-key by position and every pod stays
|
||||
consistent. Runs pre-release only (no pod has HELLO'd yet)."""
|
||||
keep_idx = [h - FIRST_GAME_HOST_ID for h in present]
|
||||
keep_tags = [self.roster[i] for i in keep_idx if 0 <= i < len(self.roster)]
|
||||
old_ids = list(present)
|
||||
print(f"[relay] LAUNCH trim: {len(keep_tags)}/{len(self.roster)} "
|
||||
f"seats present -- roster shrinks to {keep_tags}", flush=True)
|
||||
if eggmodel is not None:
|
||||
try:
|
||||
doc = eggmodel.EggDoc.load(self.egg_path)
|
||||
for i, pl in enumerate(doc.pilots()):
|
||||
if i not in keep_idx:
|
||||
doc.remove_section(pl["address"])
|
||||
doc.replace_section("pilots",
|
||||
["pilot=%s" % t for t in keep_tags])
|
||||
trimmed = os.path.join(os.path.dirname(self.egg_path) or ".",
|
||||
"_relay_trimmed.egg")
|
||||
doc.save(trimmed)
|
||||
self.egg_path = trimmed # prefs rewrite loads THIS
|
||||
self.egg_bytes = egg_wire_bytes(
|
||||
doc.emit().encode("latin-1", "replace"))
|
||||
except Exception as e:
|
||||
print(f"[relay] roster trim FAILED ({e!r}) -- launching the "
|
||||
f"FULL roster (empty seats will stall!)", flush=True)
|
||||
return
|
||||
# re-key everything by the new positions
|
||||
remap = {old: FIRST_GAME_HOST_ID + n for n, old in enumerate(old_ids)}
|
||||
self.seat_prefs = {remap[h]: v for h, v in self.seat_prefs.items()
|
||||
if h in remap}
|
||||
new_beacons = {}
|
||||
for old, conn in self.seat_beacons.items():
|
||||
if old in remap:
|
||||
conn.seat_host = remap[old]
|
||||
new_beacons[remap[old]] = conn
|
||||
self.seat_beacons = new_beacons
|
||||
self.roster = keep_tags
|
||||
self.expected_ids = set(range(FIRST_GAME_HOST_ID,
|
||||
FIRST_GAME_HOST_ID + len(keep_tags)))
|
||||
|
||||
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
|
||||
@@ -595,6 +646,20 @@ class Relay:
|
||||
# Fail()s -- the same reason the auto timer waits).
|
||||
if (self.manual_launch and self.launch_requested
|
||||
and self.launch_at is None and self.launches_sent == 0):
|
||||
if not self.eggs_released:
|
||||
# LAUNCH WITH WHOEVER CONNECTS: the operator fired before the
|
||||
# roster filled -- shrink the session to the seated players
|
||||
# and release the eggs; the launch pair fires once they ACK.
|
||||
present = sorted(set(self.seat_beacons) | set(self.by_host))
|
||||
if not present:
|
||||
if not self._launch_blocked_warned:
|
||||
self._launch_blocked_warned = True
|
||||
print("[relay] LAUNCH pressed but NO players are "
|
||||
"seated yet -- waiting for joins", flush=True)
|
||||
return
|
||||
self._launch_blocked_warned = False
|
||||
self._release_eggs(present)
|
||||
return # gate proceeds as their ACKs land
|
||||
if self.eggs_done_at is not None:
|
||||
self.launch_at = max(time.time(),
|
||||
self.eggs_done_at + LAUNCH_SETTLE_SECONDS)
|
||||
@@ -731,7 +796,8 @@ class Relay:
|
||||
# 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}
|
||||
self.seat_reservations.items()
|
||||
if t > now or h in self.seat_beacons}
|
||||
assigned = None
|
||||
for i, tag in enumerate(self.roster):
|
||||
host_id = FIRST_GAME_HOST_ID + i
|
||||
@@ -767,9 +833,15 @@ class Relay:
|
||||
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)
|
||||
conn.seat_host = host_id # this conn IS the beacon now
|
||||
self.seat_beacons[host_id] = conn
|
||||
print(f"[relay] {conn.name()} seat request -> assigned host "
|
||||
f"{host_id} '{tag}' (reserved {SEAT_RESERVE_SECONDS:.0f}s)"
|
||||
f"{pref_note}", flush=True)
|
||||
cs, mech = self.seat_prefs.get(host_id, ("", ""))
|
||||
print(f"[relay] SEAT {host_id} PRESENT tag='{tag}' "
|
||||
f"callsign='{cs}' mech='{mech}' "
|
||||
f"({len(self.seat_beacons)} seated)", flush=True)
|
||||
return
|
||||
if conn.host_id is None:
|
||||
# First frame MUST be a valid HELLO.
|
||||
@@ -842,6 +914,16 @@ class Relay:
|
||||
pass
|
||||
if conn in self.game_conns:
|
||||
self.game_conns.remove(conn)
|
||||
seat = getattr(conn, "seat_host", None)
|
||||
if seat is not None and self.seat_beacons.get(seat) is conn:
|
||||
del self.seat_beacons[seat]
|
||||
if seat not in self.by_host: # never claimed: player left
|
||||
self.seat_reservations.pop(seat, None)
|
||||
self.seat_prefs.pop(seat, None)
|
||||
i = seat - FIRST_GAME_HOST_ID
|
||||
tag = self.roster[i] if 0 <= i < len(self.roster) else "?"
|
||||
print(f"[relay] SEAT {seat} FREED tag='{tag}' "
|
||||
f"({len(self.seat_beacons)} seated)", flush=True)
|
||||
if conn.host_id is not None and self.by_host.get(conn.host_id) is conn:
|
||||
del self.by_host[conn.host_id]
|
||||
self.udp_endpoint.pop(conn.host_id, None)
|
||||
@@ -969,6 +1051,10 @@ def relay_main(argv):
|
||||
# Operator command channel: a daemon thread reads stdin. 'launch' fires
|
||||
# the mission (manual mode only); 'stop' ends it early (End Mission).
|
||||
def stdin_reader():
|
||||
if sys.stdin is None: # pythonw / broken shim pipeline
|
||||
print("[relay] no stdin -- operator commands unavailable",
|
||||
flush=True)
|
||||
return
|
||||
for line in sys.stdin:
|
||||
command = line.strip().lower()
|
||||
if command == "launch":
|
||||
|
||||
+42
-2
@@ -45,10 +45,12 @@ import eggmodel # noqa: E402
|
||||
# Pilot state model (parsed live from console/relay stdout)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ST_SEATED = "seated"
|
||||
ST_IDLE, ST_EGG, ST_REGISTERED, ST_LAUNCHED = (
|
||||
"waiting", "egg sent", "registered", "LAUNCHED")
|
||||
ST_COLORS = {ST_IDLE: "#666666", ST_EGG: "#b58900",
|
||||
ST_REGISTERED: "#2aa198", ST_LAUNCHED: "#33cc55"}
|
||||
ST_REGISTERED: "#2aa198", ST_LAUNCHED: "#33cc55",
|
||||
ST_SEATED: "#268bd2"}
|
||||
|
||||
|
||||
class SessionMonitor:
|
||||
@@ -60,6 +62,9 @@ class SessionMonitor:
|
||||
RE_RELAY_STAT = re.compile(r"\[relay-stats\] (.*)")
|
||||
RE_RELAY_DOWN = re.compile(r"game\[.* host=(\d+)\] dropped")
|
||||
RE_RELAY_READY = re.compile(r"WAITING FOR OPERATOR")
|
||||
RE_RELAY_SEAT = re.compile(
|
||||
r"SEAT (\d+) PRESENT tag='([^']*)' callsign='([^']*)' mech='([^']*)'")
|
||||
RE_RELAY_FREED = re.compile(r"SEAT (\d+) FREED tag='([^']*)'")
|
||||
RE_MESH_EGG = re.compile(r"\[([^\]]+)\] egg sent")
|
||||
RE_MESH_RUN = re.compile(r"\[([^\]]+)\] RunMission #(\d+) sent")
|
||||
|
||||
@@ -71,6 +76,7 @@ class SessionMonitor:
|
||||
self.ready = False # manual mode: awaiting operator
|
||||
self.launched = False
|
||||
self.stats = ""
|
||||
self.seat_info = {} # tag -> (callsign, mech) requests
|
||||
|
||||
def host_tag(self, host_id):
|
||||
i = host_id - 2 # hostIDs 2..N+1 in egg order
|
||||
@@ -92,6 +98,20 @@ class SessionMonitor:
|
||||
self.ready = True
|
||||
self.launched = False
|
||||
changed = True
|
||||
m = self.RE_RELAY_SEAT.search(line)
|
||||
if m:
|
||||
tag = m.group(2)
|
||||
if tag in self.state:
|
||||
self.state[tag] = ST_SEATED
|
||||
self.seat_info[tag] = (m.group(3), m.group(4))
|
||||
changed = True
|
||||
m = self.RE_RELAY_FREED.search(line)
|
||||
if m:
|
||||
tag = m.group(2)
|
||||
if tag in self.state:
|
||||
self.state[tag] = ST_IDLE
|
||||
self.seat_info.pop(tag, None)
|
||||
changed = True
|
||||
m = self.RE_RELAY_REG.search(line)
|
||||
if m:
|
||||
tag = self.host_tag(int(m.group(1)))
|
||||
@@ -661,6 +681,7 @@ class Operator(QMainWindow):
|
||||
for tag, state in self.monitor.state.items():
|
||||
parts.append('<span style="color:%s">⬤</span> %s <i>%s</i>'
|
||||
% (ST_COLORS[state], tag, state))
|
||||
seated = sum(1 for s in self.monitor.state.values() if s == ST_SEATED)
|
||||
if self.monitor.launched:
|
||||
head = "LAUNCHED — mission running"
|
||||
if (self.console_proc and not self.end_btn.isEnabled()
|
||||
@@ -668,6 +689,9 @@ class Operator(QMainWindow):
|
||||
self.end_btn.setEnabled(True)
|
||||
elif self.monitor.ready:
|
||||
head = "ALL PODS READY — press LAUNCH MISSION"
|
||||
elif seated:
|
||||
head = ("%d player(s) seated — LAUNCH MISSION starts with "
|
||||
"whoever is here" % seated)
|
||||
elif self.monitor.relay_mode:
|
||||
head = "eggs delivered: %d/%d" % (self.monitor.eggs,
|
||||
len(self.monitor.tags))
|
||||
@@ -675,8 +699,24 @@ class Operator(QMainWindow):
|
||||
head = "mesh console driving pods"
|
||||
self.pod_status.setText("<b>%s</b> %s"
|
||||
% (head, " ".join(parts)))
|
||||
# reflect walk-up callsign/mech requests in the roster table
|
||||
for r in range(self.table.rowCount()):
|
||||
item = self.table.item(r, 0)
|
||||
if item is None:
|
||||
continue
|
||||
info = self.monitor.seat_info.get(item.text().strip())
|
||||
if not info:
|
||||
continue
|
||||
callsign, mech = info
|
||||
if callsign and self.table.item(r, 1) is not None \
|
||||
and self.table.item(r, 1).text() != callsign:
|
||||
self.table.item(r, 1).setText(callsign)
|
||||
combo = self.table.cellWidget(r, 2)
|
||||
if mech and combo is not None and combo.currentText() != mech:
|
||||
combo.setCurrentText(mech)
|
||||
self.launch_btn.setEnabled(
|
||||
self.monitor.ready and not self.monitor.launched
|
||||
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
|
||||
and not self.monitor.launched
|
||||
and self.console_proc is not None)
|
||||
if self.monitor.stats:
|
||||
self.stats_label.setText(self.monitor.stats)
|
||||
|
||||
Reference in New Issue
Block a user