Self-review of3a69448(plus an adversarial pass still in flight) turned up two real defects in the work I just shipped: 1. THE STASH LIFECYCLE LEAKED ACROSS SESSIONS AND EGGS. _seat_defaults was keyed by ROW INDEX and initialised once in __init__ -- never cleared on Start Session, on Open/New egg (which rebuilds the table), or on a seat-count change. Concrete harm: stop a session while a seat is OCCUPIED (the stash is only popped when a seat empties, so it survives), reconfigure or open a different egg, start again -- and when that seat next empties the GUI restores LAST SESSION'S snapshot over the new configuration. Fixed by (a) keying the stash by TAG, the seat's actual identity, so a changed roster makes stale keys inert instead of landing on whatever row now sits at that index, and (b) clearing it at both natural boundaries: session start (both local and remote paths -- the clear sits before the remote branch) and the table rebuild in _load_egg_into_ui. 2. POSITIONAL hostID->tag MAPPING IS WRONG AFTER A LAUNCH TRIM -- and3a69448widened its blast radius. The GUI resolves REGISTERED/dropped lines via host_tag(), position into the UNTRIMMED egg roster; after a trim remaps seat ids (e.g. the lone returning player reclaim-held at seat 3 becomes host 2), those lines light the wrong row -- and, with the new seat_info pop on dropped, CLEAR the wrong seat's identity, leaving the real one's name up: the exact reported bug, resurfacing in the trimmed case. Fixed on both sides: the relay's REGISTERED and dropped prints now carry tag='...' from its LIVE (possibly trimmed) roster via a new _tag_of() helper, and the GUI regexes prefer the explicit tag with the positional fallback kept for old logs. SEATED/READY/LEFT already carried tags; only these two were positional. VERIFIED * test_operator_roster.py grown to 22 checks, all passing: post-trim lines light and clear the RIGHT row while the wrong row stays untouched; the legacy no-tag format still resolves positionally; a stopped-while-occupied session cannot leak GHOST into the next session's empty seat; a stale foreign-tag stash never touches a live row. * Live 2-pod rig: both new formats confirmed on the wire (REGISTERED (1/2) tag='...' / dropped: closed tag='...'), mission launched and stopped cleanly, round RESET intact. rearm 19/19, net 17/17. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
6.7 KiB
Python
156 lines
6.7 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=== POST-TRIM: explicit tag beats the positional fallback ===")
|
|
# After a LAUNCH trim the relay remaps seat ids: host 2 can be roster position 3.
|
|
# The relay now prints tag='...' on REGISTERED/dropped; the monitor must use it.
|
|
if len(tags) > 1:
|
|
# relay says host 2, but the tag names row 1 -- the trimmed truth
|
|
feed("[relay] PLAYER 2 SEATED (host 2) tag='%s' callsign='TRIMMED' mech='thor'"
|
|
% tags[1])
|
|
feed("[relay] game[5.5.5.5:5 host=2] REGISTERED (1/1) tag='%s'" % tags[1])
|
|
check("explicit tag lights the RIGHT row", light(1) == btoperator.ST_REGISTERED,
|
|
str(light(1)))
|
|
check("row 0 not falsely lit", light(0) != btoperator.ST_REGISTERED,
|
|
str(light(0)))
|
|
feed("[relay] game[5.5.5.5:5 host=2] dropped: closed tag='%s'" % tags[1])
|
|
check("explicit tag clears the RIGHT row", light(1) == btoperator.ST_IDLE,
|
|
str(light(1)))
|
|
check("the departed name cleared on the right row too",
|
|
callsign(1) != "TRIMMED", callsign(1))
|
|
# old-format line (no tag=): the positional fallback must still work
|
|
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='OLDFMT' mech='lok2'"
|
|
% tags[0])
|
|
feed("[relay] game[5.5.5.5:5 host=2] REGISTERED (1/1)")
|
|
check("legacy line (no tag) still resolves positionally",
|
|
light(0) == btoperator.ST_REGISTERED, str(light(0)))
|
|
feed("[relay] game[5.5.5.5:5 host=2] dropped: closed")
|
|
|
|
print("\n=== stash survives NOTHING it should not: stopped-while-occupied ===")
|
|
# A session stopped while a seat is occupied never pops the stash. The next
|
|
# session must not restore last session's snapshot -- _start_session clears it.
|
|
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='GHOST' mech='lok2'"
|
|
% tags[0])
|
|
check("occupied at 'session stop'", callsign(0) == "GHOST", callsign(0))
|
|
# simulate the parts of _start_session that matter (no relay spawn)
|
|
win._seat_defaults.clear()
|
|
win.monitor = btoperator.SessionMonitor(True, tags)
|
|
win.table.item(0, 1).setText("FRESH_CFG") # operator reconfigures
|
|
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='NEWSESSION' mech='thor'"
|
|
% tags[0])
|
|
feed("[relay] game[8.8.8.8:8 host=2] dropped: closed")
|
|
check("post-restart empty seat shows the NEW config, not GHOST",
|
|
callsign(0) == "FRESH_CFG", callsign(0))
|
|
|
|
print("\n=== stash is TAG-keyed: an egg reload makes old keys inert ===")
|
|
win._seat_defaults["10.99.9.9:9999"] = ("STALE", "lok2") # a tag not in this roster
|
|
feed("[relay] PLAYER 1 SEATED (host 2) tag='%s' callsign='ABC' mech='lok2'" % tags[0])
|
|
feed("[relay] game[8.8.8.8:8 host=2] dropped: closed")
|
|
check("a stale foreign-tag stash never touches a live row",
|
|
callsign(0) == "FRESH_CFG", callsign(0))
|
|
|
|
print("\n%s" % ("ALL CHECKS PASSED" if not fails else "FAILURES: %s" % fails))
|
|
sys.exit(1 if fails else 0)
|