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:
co-authored by
Claude Opus 5
parent
ab5828a866
commit
3a694485bf
@@ -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
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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> %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
|
||||
|
||||
Reference in New Issue
Block a user