diff --git a/context/multiplayer.md b/context/multiplayer.md index 5420dda..b797b63 100644 --- a/context/multiplayer.md +++ b/context/multiplayer.md @@ -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 diff --git a/engine/MUNGA_L4/L4NET.CPP b/engine/MUNGA_L4/L4NET.CPP index 1fa5545..45a4f32 100644 --- a/engine/MUNGA_L4/L4NET.CPP +++ b/engine/MUNGA_L4/L4NET.CPP @@ -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( diff --git a/tools/btconsole.py b/tools/btconsole.py index b8b9f24..cd49737 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -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(" 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": diff --git a/tools/btoperator.py b/tools/btoperator.py index b6fa725..890048b 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -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(' %s %s' % (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("%s    %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)