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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d26d0375ae
commit
196112f2dc
+25
-4
@@ -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
|
||||
|
||||
+18
-3
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user