From 196112f2dce2c71d5827a011d70a8d126357c0a4 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sat, 18 Jul 2026 18:25:19 -0500 Subject: [PATCH] Fix seat-collision: reserve the operator's LOCAL seats from auto-assign FIELD BUG (2026-07-18): operator's local instance crashed + the mission never launched. Root cause (confirmed by code inspection + stub test, NOT the earlier mis-read of stale logs): the relay assigns a no-BT_SELF joiner 'the lowest free seat'. The operator's local instance uses an EXPLICIT seat (BT_SELF) but takes ~15s to boot; a remote join.bat requests a seat immediately and gets assigned the operator's seat 1 first. The operator's later HELLO for seat 1 -> 'already registered' -> dropped ('closed by relay') -> its seat sits empty -> the All-connections-completed gate never fires -> game never starts. Fix: the operator's LOCAL seats are RESERVED. btoperator passes the Local roster tags as ; the relay excludes those host_ids from seat ASSIGNMENT (an explicit HELLO still claims them). Verified by stub test: a racing joiner is assigned host 3 (seat 1 skipped), the operator's HELLO for the reserved seat 1 registers fine. Also fixed the UX bug that produced the EARLIER 4-window mess: Local now defaults checked only on the operator's own seat (row 0), not every relay roster row. Co-Authored-By: Claude Fable 5 --- tools/btconsole.py | 29 +++++++++++++++++++++++++---- tools/btoperator.py | 21 ++++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/tools/btconsole.py b/tools/btconsole.py index 934bfac..43b289d 100644 --- a/tools/btconsole.py +++ b/tools/btconsole.py @@ -276,12 +276,13 @@ class RelayConsoleConn: class Relay: def __init__(self, console_port, egg_path, bind_addr, udp_drop_pct, - manual_launch=False): + manual_launch=False, reserved_tags=None): self.console_port = console_port self.game_port = console_port + 1 self.bind_addr = bind_addr self.udp_drop_pct = udp_drop_pct self.manual_launch = manual_launch + self._reserved_tags = set(reserved_tags or ()) self.launch_requested = False # set by the operator (stdin) self.stop_requested = False # operator 'stop' (End Mission) self.mission_length = parse_egg_mission_length(egg_path) @@ -292,6 +293,19 @@ class Relay: self.roster = parse_egg_roster(egg_path) self.expected_ids = set(range(FIRST_GAME_HOST_ID, FIRST_GAME_HOST_ID + len(self.roster))) + # OPERATOR-RESERVED SEATS: roster seats the operator flies LOCALLY (with + # an explicit BT_SELF). Those instances take ~15s to boot, so a remote + # join.bat (no BT_SELF -> SEAT_REQUEST) would otherwise be assigned the + # operator's seat first, and the operator's later HELLO would collide + # ("already registered" -> dropped -> game never launches). Seat + # ASSIGNMENT skips these host_ids; the explicit HELLO still claims them. + self.reserved_host_ids = set() + for i, tag in enumerate(self.roster): + if tag in self._reserved_tags: + self.reserved_host_ids.add(FIRST_GAME_HOST_ID + i) + if self.reserved_host_ids: + print(f"[relay] operator-reserved seats (no auto-assign): " + f"{sorted(self.reserved_host_ids)}", flush=True) self.sel = selectors.DefaultSelector() self.console_conns = [] self.game_conns = [] # RelayGameConn (incl. unregistered) @@ -556,8 +570,9 @@ class Relay: 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 + if (host_id in self.by_host or host_id in self.seat_reservations + or host_id in self.reserved_host_ids): + continue # claimed / racing / operator-local assigned = (host_id, tag) break if assigned is None: @@ -753,12 +768,18 @@ def relay_main(argv): if "--manual-launch" in args: manual = True args.remove("--manual-launch") + reserved_tags = () + if "--reserve" in args: + i = args.index("--reserve") + reserved_tags = tuple(t for t in args[i + 1].split(",") if t) + del args[i:i + 2] if len(args) != 2: print(__doc__) return 2 console_port = int(args[0]) egg_path = args[1] - relay = Relay(console_port, egg_path, bind_addr, udp_drop, manual) + relay = Relay(console_port, egg_path, bind_addr, udp_drop, manual, + reserved_tags=reserved_tags) if not relay.roster: print(f"[relay] ERROR: no [pilots] entries in {egg_path}") return 2 diff --git a/tools/btoperator.py b/tools/btoperator.py index dc05884..d353a6d 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -418,7 +418,10 @@ class Operator(QMainWindow): p = self._pilot_template() p["address"] = address p.pop("name", None) # new row gets the PLAYERn default callsign - self._add_pilot_row(p, local=(self.mode.currentIndex() == 0)) + # a NEWLY-added pilot is a remote joiner by default (Local only on the + # operator's own seat, normally row 0 which the egg load already sets) + self._add_pilot_row(p, local=(self.mode.currentIndex() == 0 + and self.table.rowCount() == 0)) self._renumber_relay_tags() def _remove_pilot(self): @@ -458,8 +461,12 @@ class Operator(QMainWindow): self.f_length.setText(m.get("length") or "") self.table.setRowCount(0) relay = self.mode.currentIndex() == 0 - for p in self.egg.pilots(): - self._add_pilot_row(p, local=relay) + # LOCAL default: ONLY the operator's own seat (row 0) -- NOT every row. + # (Checking Local on all rows made "Launch local instances" spawn one + # full game client PER seat on this machine, which collided and + # crashed -- field report 2026-07-18.) Remote joiners stay unchecked. + for i, p in enumerate(self.egg.pilots()): + self._add_pilot_row(p, local=(relay and i == 0)) self._renumber_relay_tags() self.egg_label.setText(os.path.basename(self.egg_path)) @@ -545,6 +552,14 @@ class Operator(QMainWindow): args += ["--relay", str(self.f_port.value()), self.egg_path] if self.f_manual.isChecked(): args += ["--manual-launch"] + # RESERVE the operator's LOCAL seats so a remote join.bat can't be + # assigned one before the (slow-to-boot) local instance claims it + # -- the seat-collision that dropped the operator's pod + stalled + # the launch (field report 2026-07-18). + local_tags = [tags[r] for r in range(self.table.rowCount()) + if self._row_local(r) and r < len(tags)] + if local_tags: + args += ["--reserve", ",".join(local_tags)] else: # mesh: console dials each pod's CONSOLE port (= game port - 1) args += [self.egg_path]