diff --git a/context/operator-console.md b/context/operator-console.md index 9a7ef20..ed71895 100644 --- a/context/operator-console.md +++ b/context/operator-console.md @@ -94,6 +94,44 @@ host id, which is why a roster **trim remaps seat ids** and everything re-keys b **Launch with whoever connects:** the operator's LAUNCH can `_trim_roster` down to the seats that actually have live beacons or claims, so a 8-seat egg runs a 3-player round. +### When a player leaves + +Relay side: the beacon's FIN frees the seat (`seat_beacons` entry deleted, and `seat_reservations` / +`seat_prefs` dropped), but the TAG is **held for that player for 90 s** (`seat_reclaim`) so a crash +or a reconnect returns them to the same seat with the same mech. Seat assignment skips seats that +are claimed, reserved, operator-reserved, or reclaim-held — so a brand-new joiner in that 90 s window +gets the next free seat instead, or `ROSTER FULL` if that was the only one. The hold expires on its +own; nothing is held permanently. + +**`PLAYER n LEFT` is NOT printed for every departure.** It is inside the beacon-close branch gated +on `if seat not in self.by_host:` — the relay's own comment reads *"never claimed: player left"*. A +player who was fully REGISTERED and then drops produces only `game[...] dropped` (+ PEER_DOWN). Any +UI that keys "seat is empty" off the LEFT line alone will therefore miss the common case — which is +exactly the bug below. + +### The roster kept a departed player's name [FIXED 2026-07-26] + +Operator report: *"if a player disconnects, it leaves their name up in the roster ... it should turn +their seat back to empty."* The GUI only ever **wrote** walk-up names into the table: on a +disconnect `seat_info` was popped and the write-back loop hit `if not info: continue`, so the cell +kept the departed callsign for the rest of the session — while the pilot light beside it correctly +said `waiting`. The table and the light disagreed, and a free seat looked occupied. + +Two changes, because the two clearing paths are not symmetric: + +1. `SessionMonitor` now pops `seat_info` when a pilot goes idle from **either** signal — the + `game[...] dropped` line as well as `PLAYER n LEFT` — so a registered player's departure clears + the identity too. +2. `_refresh_pod_status` stashes the operator's configured callsign/mech **per occupancy** when a + seat fills, and **restores it** when the seat empties. Per-occupancy (rather than once at egg + load) means an edit the operator makes while a seat is empty is respected, not overwritten by a + stale snapshot. + +It restores the **configured pilot**, not a literal blank, on purpose: that cell is editable and is +what gets written to the egg on Save, so a placeholder like "-- empty --" would end up in the +mission file. The "unoccupied" signal is the grey `waiting` light next to it. +Regression guard: `scratchpad/test_operator_roster.py` (drives the real widget offscreen). + --- ## The round lifecycle (the part that used to strand the operator) diff --git a/docs/OPERATOR_GUIDE.md b/docs/OPERATOR_GUIDE.md index 83c8213..8a45c7b 100644 --- a/docs/OPERATOR_GUIDE.md +++ b/docs/OPERATOR_GUIDE.md @@ -137,7 +137,20 @@ control link rather than restarting that relay. - **`Public host`** is used only when exporting player scripts; it does not affect what the relay binds or advertises. -## 7. Troubleshooting +## 7. When a player leaves + +Their pilot light goes grey (**waiting**) and the seat's callsign reverts to whatever you had +configured for that row — so a free seat no longer looks occupied by a departed player. + +**Their seat is held for them for 90 seconds.** That is deliberate: someone who crashes or gets +dropped comes straight back into the same seat with the same mech. During that window a *brand-new* +player is given the next free seat instead, or told the roster is full if there wasn't one. After 90 +seconds the vacated seat is fair game for anybody. + +So if a new arrival can't get in right after somebody left, wait a minute and have them retry — or +raise the seat count if you're simply out of seats. + +## 8. Troubleshooting | What you see | What it means | What to do | |---|---|---| @@ -162,7 +175,7 @@ diagnosable. --- -## 8. Match reports +## 9. Match reports At the end of each clean round, every player's client **uploads its own match report to you automatically** (relay mode only). They land in `matchlogs\` named by the sender's address. You do @@ -175,7 +188,7 @@ for it explicitly when something went wrong on their end. --- -## 9. Local test instances +## 10. Local test instances **Launch local instances** starts game clients on this machine against your own session — useful for testing alone or filling a seat. **Stop games** closes them. In relay mode the session must @@ -183,7 +196,7 @@ already be started, or a local instance has nothing to dial. --- -## 10. Which build everyone needs +## 11. Which build everyone needs **All pods must run the same build as each other.** Build **4.11.554** changed what pods tell each other about scores, so a mixed session shows disagreeing KILLS/DEATHS columns (nothing crashes — diff --git a/scratchpad/test_operator_roster.py b/scratchpad/test_operator_roster.py new file mode 100644 index 0000000..5516f43 --- /dev/null +++ b/scratchpad/test_operator_roster.py @@ -0,0 +1,107 @@ +"""Roster-display test: a departed player must not keep their seat (2026-07-26). + +Operator report: "if a player disconnects from the console, it leaves their name +up in the roster ... it should turn their seat back to empty." + +Drives the REAL Qt widget offscreen and feeds it the REAL relay log lines, then +asserts what the roster table shows. No relay, no game, no visible window. +""" +import os +import sys + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +REPO = r"C:\git\bt411" +sys.path.insert(0, os.path.join(REPO, "tools")) + +fails = [] + + +def check(label, ok, extra=""): + print(" %-58s %s %s" % (label, "OK" if ok else "*** FAIL ***", extra)) + if not ok: + fails.append(label) + + +from PySide6.QtWidgets import QApplication # noqa: E402 +import btoperator # noqa: E402 + +app = QApplication.instance() or QApplication([]) +win = btoperator.Operator() + +# Relay mode with a fresh monitor over the roster the UI actually built. +tags = [] +for r in range(win.table.rowCount()): + tags.append(win.table.item(r, 0).text().strip()) +win.monitor = btoperator.SessionMonitor(True, tags) +print(" roster rows: %d first tag: %s" % (len(tags), tags[0] if tags else "-")) + + +def callsign(row): + it = win.table.item(row, 1) + return it.text() if it is not None else None + + +def light(row): + return win.monitor.state.get(tags[row]) + + +def feed(line): + win.monitor.feed(line) + win._refresh_pod_status() + + +configured = callsign(0) +print(" row 0 configured callsign: %r" % configured) + +print("\n=== a walk-up player takes seat 1 ===") +feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='SAURON' mech='lok2'" + % tags[0]) +check("roster shows the player's callsign", callsign(0) == "SAURON", callsign(0)) +check("pilot light is 'seated'", light(0) == btoperator.ST_SEATED, str(light(0))) + +print("\n=== they register, then DISCONNECT (the reported case) ===") +feed("[relay] game[1.2.3.4:5555 host=2] REGISTERED (1/2)") +check("light is 'registered'", light(0) == btoperator.ST_REGISTERED, str(light(0))) +feed("[relay] game[1.2.3.4:5555 host=2] dropped: closed") +check("pilot light returns to 'waiting'", light(0) == btoperator.ST_IDLE, + str(light(0))) +check("SEAT NO LONGER SHOWS THE DEPARTED NAME", callsign(0) != "SAURON", + callsign(0)) +check("seat restored to its configured pilot", callsign(0) == configured, + callsign(0)) + +print("\n=== the seat is reusable: a DIFFERENT player takes it ===") +feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='NEWGUY' mech='thor'" + % tags[0]) +check("the new player's callsign shows", callsign(0) == "NEWGUY", callsign(0)) +feed("[relay] game[9.9.9.9:1 host=2] dropped: closed") +check("and it clears again for them too", callsign(0) == configured, callsign(0)) + +print("\n=== the other clearing path: 'PLAYER n LEFT' (never claimed) ===") +feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='WALKUP' mech='owens'" + % tags[0]) +check("occupied again", callsign(0) == "WALKUP", callsign(0)) +feed("[relay] PLAYER 1 LEFT (host 2) tag='%s' (0 seated)" % tags[0]) +check("LEFT also clears the seat", callsign(0) == configured, callsign(0)) +check("light is 'waiting'", light(0) == btoperator.ST_IDLE, str(light(0))) + +print("\n=== an operator edit made while the seat is EMPTY must survive ===") +win.table.item(0, 1).setText("MY_DEFAULT") +feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='VISITOR' mech='lok2'" + % tags[0]) +check("visitor's name shows while seated", callsign(0) == "VISITOR", callsign(0)) +feed("[relay] game[7.7.7.7:7 host=2] dropped: closed") +check("reverts to the operator's NEW edit, not the old one", + callsign(0) == "MY_DEFAULT", callsign(0)) + +print("\n=== an untouched seat is never rewritten ===") +if len(tags) > 1: + before = callsign(1) + feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='X' mech='lok2'" + % tags[0]) + check("row 1 untouched by row 0 traffic", callsign(1) == before, + "%r vs %r" % (callsign(1), before)) + +print("\n%s" % ("ALL CHECKS PASSED" if not fails else "FAILURES: %s" % fails)) +sys.exit(1 if fails else 0) diff --git a/tools/btoperator.py b/tools/btoperator.py index 15e8ef6..e1957f2 100644 --- a/tools/btoperator.py +++ b/tools/btoperator.py @@ -163,6 +163,14 @@ class SessionMonitor: tag = self.host_tag(int(m.group(1))) if tag and self.state.get(tag) != ST_IDLE: self.state[tag] = ST_IDLE + # ALSO forget the walk-up identity. "PLAYER n LEFT" -- the + # other line that clears a seat -- is only printed by the + # relay when the player never CLAIMED its seat, so a + # registered player dropping mid-session produced only this + # `dropped` line. Without the pop, seat_info kept their + # callsign and the roster showed a departed pilot as still + # sitting there (operator report 2026-07-26). + self.seat_info.pop(tag, None) changed = True m = self.RE_RELAY_HELD.search(line) if m: @@ -303,6 +311,10 @@ class Operator(QMainWindow): self.egg_path = DEFAULT_EGG self.console_proc = None self.remote_link = None + # row -> (configured callsign, configured mech), stashed while that seat + # is occupied by a walk-up player and restored when it empties, so a + # departed pilot's name does not sit in the roster (report 2026-07-26). + self._seat_defaults = {} self.game_procs = [] self.monitor = None self._build_ui() @@ -1069,19 +1081,44 @@ 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 + # Reflect walk-up callsign/mech requests in the roster table -- AND put a + # seat back to its configured default when its player leaves. + # + # This used to only ever WRITE a name in: on a disconnect the relay pops + # seat_info, the loop hit `if not info: continue`, and the departed + # player's callsign sat in the roster for the rest of the session. The + # pilot light next to it correctly said "waiting", so the table and the + # light disagreed and the seat looked occupied when it was free + # (operator report 2026-07-26). + # + # Restoring the OPERATOR'S CONFIGURED value -- not a literal blank -- is + # deliberate: this cell is editable and is what gets written to the egg on + # Save, so stuffing a placeholder like "-- empty --" in here would put + # that placeholder into the mission file. 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) + tag = item.text().strip() + info = self.monitor.seat_info.get(tag) + cell = self.table.item(r, 1) combo = self.table.cellWidget(r, 2) + if info: + # Occupied. On the FIRST refresh of this occupancy, stash what + # the operator had configured so we can put it back later. Doing + # it per-occupancy (rather than once at load) means an edit the + # operator makes while a seat is empty is respected. + if r not in self._seat_defaults: + self._seat_defaults[r] = ( + cell.text() if cell is not None else None, + combo.currentText() if combo is not None else None) + callsign, mech = info + else: + # Empty: restore the configured pilot and forget the stash, so + # the next player to take this seat re-snapshots. + callsign, mech = self._seat_defaults.pop(r, (None, None)) + if callsign and cell is not None and cell.text() != callsign: + cell.setText(callsign) if mech and combo is not None and combo.currentText() != mech: combo.setCurrentText(mech) # A REMOTE operator has no console_proc -- its command channel is the