Files
BT411/scratchpad/test_operator_roster.py
T
arcattackandClaude Opus 5 3a694485bf 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>
2026-07-26 08:43:18 -05:00

108 lines
4.0 KiB
Python

"""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)