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>
108 lines
4.0 KiB
Python
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)
|