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:
arcattack
2026-07-22 14:40:47 -05:00
co-authored by Claude Opus 4.8
parent 95735db5ae
commit 10eee05b20
4 changed files with 158 additions and 5 deletions
+42 -2
View File
@@ -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> &nbsp;&nbsp; %s"
% (head, " &nbsp; ".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)