operator UI: a departed player no longer keeps their seat in the roster

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."  I had not noticed
or fixed this -- the earlier review raised adjacent roster issues (lights mapped
through the untrimmed tag list after a trim) but not this one.

CAUSE.  The GUI only ever WROTE walk-up names into the table.  On a disconnect
the relay pops seat_info, the write-back loop hit `if not info: continue`, and
the cell kept the departed callsign for the rest of the session -- while the
pilot light beside it correctly said "waiting".  So the table and the light
disagreed and a free seat looked occupied.

Two changes, because the relay's two seat-clearing signals are NOT symmetric:

  * SessionMonitor now pops seat_info when a pilot goes idle from EITHER signal.
    "PLAYER n LEFT" is only printed inside the beacon-close branch gated on
    `if seat not in self.by_host` (the relay's own comment: "never claimed:
    player left"), so a player who was fully REGISTERED and then dropped
    produced only `game[...] dropped` -- the common case, and the one that left
    the name up.  Keying "seat is empty" off LEFT alone could never have worked.
  * _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, so an edit the operator makes
    while a seat is empty is respected instead of being overwritten by a stale
    snapshot.

It restores the CONFIGURED PILOT, not a literal blank, deliberately: 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 beside it.

NOT CHANGED, and explained instead: a vacated seat is held for its player for 90s
(seat_reclaim) so a crash or a reconnect returns them to the same seat and mech.
Assignment skips claimed/reserved/reclaim-held seats, so a brand-new joiner in
that window gets the next free seat, or ROSTER FULL if there wasn't one; after 90s
the seat is fair game.  The operator said they were happy with players keeping
their seat/address, so this stays -- it is now documented in both docs rather than
silently removed.

VERIFIED: scratchpad/test_operator_roster.py (new, 14 checks) drives the REAL Qt
widget offscreen and feeds it the REAL relay log lines: a walk-up name appears;
after REGISTERED then `dropped` the light returns to waiting AND the seat shows
the configured pilot again; the seat is reusable by a different player and clears
for them too; the `PLAYER n LEFT` path clears it as well; an operator edit made
while the seat was empty survives an occupancy; and a neighbouring row is never
touched by another seat's traffic.  Other suites still pass (rearm 19/19, net
17/17); checkctx CLEAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-26 08:43:18 -05:00
co-authored by Claude Opus 5
parent ab5828a866
commit 3a694485bf
4 changed files with 207 additions and 12 deletions
+38
View File
@@ -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)
+17 -4
View File
@@ -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 —
+107
View File
@@ -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)
+45 -8
View File
@@ -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("<b>%s</b> &nbsp;&nbsp; %s"
% (head, " &nbsp; ".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