console review round 2: six must-fixes from the adversarial pass (one of its fixes corrected)
The 4-dimension review of 4736cba..820caf8 returned 14 surviving findings. All six must-fixes plus the four deferables are in; one of the review's own prescriptions was wrong and is fixed differently (below). THE REAPER, FINAL DOCTRINE (blocker + major). Rev 2 -- "reap anything silent in the staging window" -- was still wrong twice over: a pad sends exactly ONE message in its life (the egg ACK, L4NET.CPP:1259) and is then silent FOREVER, so any manual-launch hold >180s reaped every healthy ACKed pad and force-relaunched their clients; and a REGISTERED game conn is quiet on TCP while loading, so with BT_RELAY_TCP_ONLY=1 (or blocked UDP) the reaper _abort_round()ed the whole night every ~3 minutes blaming a healthy player. The rule is now: an app-level deadline is valid only while a RESPONSE IS OWED. Only egg-sent-never-ACKed pads are reaped, with the debt clock starting at EGG SEND (a pad that sat through a long held-egg wait gets its full window -- an edge neither review round caught); game conns are never reaped at all. Half-open ghosts are TCP keepalive's job (~130s, faster than the deadline anyway). RE-ARM GUARDED ON EVERY CHANNEL (major). The launch_at guard lived only on the LAUNCH-press path; the ctl `rearm` command, stdin, and the always-lit GUI button reached _rearm_for_new_round unconditionally -- one press mid-mission zeroed the counters, permanently killing the mission clock AND making End Mission print "no mission is running": an unstoppable round, the exact class this feature exists to eliminate. Now refused (loudly) while a mission is running or a pair is in flight, and the button greys while launched. THE REVIEW'S OWN FIX WAS WRONG here: it prescribed refusing on `launches_sent >= 2`, but that stays 2 after a FINISHED round -- applying it verbatim resurrected the original dead-LAUNCH wedge, caught immediately by the regression suite. "Running" is `launches_sent >= 2 AND not stop_sent`. RE-ARM RE-KEYS SEATS (major). It restored the template roster but left seat_beacons/seat_prefs keyed by the trimmed round's positional ids, so round 2's trim minted a departed player's tag into the egg and trimmed a present player's out, shifting every callsign/mech a slot. The re-key block is factored out of _maybe_reset_round (_rekey_seats_to_roster) and shared. RESTART SESSION NO LONGER BAKES A WALK-UP'S NAME INTO THE EGG (major). _stop_session and _start_session now restore the stashed configured callsign/mech into the cells BEFORE _collect_egg can snapshot them (_restore_seat_defaults); previously the departed name went into the egg (name= plus rasterized bitmaps) and was then re-captured as the seat's permanent "configured default". LATE REMOTE OPERATOR GETS A ROSTER (major). The only line the GUI can adopt tags from was printed once at relay startup and aged out of the 400-line control history in ~33 minutes of stats chatter -- AUTH now re-issues the live roster line ahead of the replay, so a remote operator connecting at any point gets pilot lights and a working LAUNCH button. DEFERABLES, all four: _drop_game blames the tag stashed AT REGISTRATION (the live-roster resolve named the wrong tag for a trimmed-round conn dying after a re-arm restore -- and the tag-trusting GUI would clear the wrong seat); an operator-BLANKED callsign cell now restores (empty string is a real configured value; the falsy skip left the departed name up and re-captured it); a returning player displaces their own half-open registered ghost (same IP, no mission running) instead of eating ROSTER FULL until keepalive fires; udp_spoofed now rides the [relay-stats] line when non-zero and the warn set is capped at 256. VERIFIED: rearm suite grown to 25 checks (ACKed pad never reaped however silent; never-ACKed pad reaped; game conns never reaped; the egg-send debt clock; re-arm refused mid-mission, allowed after round end) -- plus net 17/17, roster 22/22, checkctx CLEAN. Live rig: mid-mission `rearm` refused with the message and the mission survived; End Mission -> re-arm -> full re-seat -> second mission launched. One bounded artifact observed and documented: each waiting pod bounces once (identity resync) after an explicit re-arm. Docs rewritten to the final doctrine (context/operator-console.md reaper + re-arm + roster-replay + udp_spoofed sections; OPERATOR_GUIDE + tooltip now say re-arm is between-rounds-only and warn about the one-bounce resync). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
820caf8765
commit
c9e561d9ba
+138
-64
@@ -657,6 +657,7 @@ class Relay:
|
||||
_send_all_guarded(conn.sock, pkt, "egg chunk")
|
||||
n += 1
|
||||
conn.egg_sent = True
|
||||
conn.egg_sent_at = time.time() # the ACK-debt clock starts NOW
|
||||
print(f"[relay] console conn {addr[0]}:{addr[1]}: egg sent "
|
||||
f"({n} chunks); awaiting pod ACK", flush=True)
|
||||
except OSError as e:
|
||||
@@ -803,6 +804,30 @@ class Relay:
|
||||
print(f"[relay] seat-pref egg rewrite FAILED ({e!r}) -- "
|
||||
f"serving the original egg", flush=True)
|
||||
|
||||
def _rekey_seats_to_roster(self):
|
||||
"""Re-key live beacons/prefs by each TAG's position in the CURRENT
|
||||
roster. Needed whenever the roster changes shape (round reset, re-arm):
|
||||
seat ids are positional, so a beacon keyed by a TRIMMED round's host id
|
||||
points at the wrong seat once the template roster is restored -- a
|
||||
departed player's tag was being minted into the next round's egg while a
|
||||
present player's was trimmed out (review 2026-07-26). Beacons whose tag
|
||||
is no longer in the roster are dropped from the maps (their seats free
|
||||
up); reservations are cleared wholesale, matching the round-reset
|
||||
semantics."""
|
||||
old_beacons = self.seat_beacons
|
||||
old_prefs = self.seat_prefs
|
||||
self.seat_beacons = {}
|
||||
self.seat_prefs = {}
|
||||
self.seat_reservations = {}
|
||||
for old_id, conn in old_beacons.items():
|
||||
tag = getattr(conn, "seat_tag", None)
|
||||
if tag in self.roster:
|
||||
new_id = FIRST_GAME_HOST_ID + self.roster.index(tag)
|
||||
conn.seat_host = new_id
|
||||
self.seat_beacons[new_id] = conn
|
||||
if old_id in old_prefs:
|
||||
self.seat_prefs[new_id] = old_prefs[old_id]
|
||||
|
||||
def _maybe_reset_round(self):
|
||||
# All pods gone from a FINALIZED round -> restore the template so the
|
||||
# next round's joins are honored (held egg, fresh prefs/trim). Live
|
||||
@@ -827,78 +852,55 @@ class Relay:
|
||||
self.stop_sent = False
|
||||
self.stop_requested = False
|
||||
self.mission_started_at = None
|
||||
old_beacons = self.seat_beacons
|
||||
old_prefs = self.seat_prefs
|
||||
self.seat_beacons = {}
|
||||
self.seat_prefs = {}
|
||||
self.seat_reservations = {}
|
||||
for old_id, conn in old_beacons.items():
|
||||
tag = getattr(conn, "seat_tag", None)
|
||||
if tag in self.roster:
|
||||
new_id = FIRST_GAME_HOST_ID + self.roster.index(tag)
|
||||
conn.seat_host = new_id
|
||||
self.seat_beacons[new_id] = conn
|
||||
if old_id in old_prefs:
|
||||
self.seat_prefs[new_id] = old_prefs[old_id]
|
||||
self._rekey_seats_to_roster()
|
||||
print(f"[relay] round RESET -- roster/egg restored "
|
||||
f"({len(self.seat_beacons)} player(s) already waiting)",
|
||||
flush=True)
|
||||
|
||||
def _reap_dead_peers(self):
|
||||
"""Age out peers that stopped speaking entirely.
|
||||
"""Age out a peer that OWES a protocol response and has gone silent.
|
||||
|
||||
TCP keepalive (set on accept) handles most half-open sockets, but it is
|
||||
best-effort and platform-dependent, and a peer can also be alive at the
|
||||
TCP level while its game process is gone. A silent connection is what
|
||||
used to pin the relay: an acked console conn blocks the round reset
|
||||
forever, and a registered game conn holds its by_host seat, so BOTH
|
||||
launch re-arm paths stay unreachable and the operator has to restart.
|
||||
THE DOCTRINE (rewritten twice in review, 2026-07-26 -- keep it): an
|
||||
app-level deadline is only valid where silence is ABNORMAL, i.e. while a
|
||||
response is owed. Everything else is TCP keepalive's job (set on every
|
||||
accept; ~130s detection via SIO_KEEPALIVE_VALS), which kills the socket
|
||||
and lands in the normal recv/drop path.
|
||||
|
||||
Deliberately generous (DEAD_PEER_SECONDS): a pod legitimately sends
|
||||
nothing while sitting at the front end between rounds, so this must only
|
||||
catch the genuinely gone. Never applied while a mission is running --
|
||||
an in-mission pod that goes quiet is the netcode's problem, not ours.
|
||||
What a healthy peer's silence looks like, so nobody re-widens this:
|
||||
* a console pad sends exactly ONE message in its life -- the egg ACK.
|
||||
After ACKing it is silent FOREVER. The first cut reaped ACKed pads
|
||||
180s into any staging hold (a manual-launch operator waiting on a
|
||||
straggler), force-relaunching every healthy client. [blocker]
|
||||
* a REGISTERED game conn is loading the mission: quiet on TCP until
|
||||
READY, and its 15s UDP HELLOs never arrive if the pod runs
|
||||
BT_RELAY_TCP_ONLY=1 or UDP is blocked (the documented TCP fallback).
|
||||
Reaping it _abort_round()s the whole night, every ~3 minutes,
|
||||
blaming a healthy player. [major]
|
||||
* between rounds every pod is byte-silent by design (the seat beacon
|
||||
is write-only: L4NET.CPP "the relay ignores its silence").
|
||||
|
||||
So the ONLY thing this reaps is a console pad that was sent the egg and
|
||||
never ACKed within DEAD_PEER_SECONDS -- a wedged or dead client holding
|
||||
the eggs_done gate open. Debt paid (acked) = exempt. No egg = exempt.
|
||||
Game conns are never reaped here at all.
|
||||
"""
|
||||
if self.launches_sent >= 2:
|
||||
return # mission running: leave peers alone
|
||||
if not self.eggs_released:
|
||||
#
|
||||
# BETWEEN ROUNDS / BEFORE THE EGG GOES OUT, SILENCE IS CORRECT and
|
||||
# can last a long time (a real night showed 12- and 5-minute gaps
|
||||
# while the operator set up the next mission). A waiting pod has
|
||||
# nothing to send: its seat beacon is deliberately write-only for the
|
||||
# process lifetime -- L4NET.CPP: "the relay ignores its silence" --
|
||||
# and its console pad has no egg yet, so it cannot ACK. Reaping here
|
||||
# would drop HEALTHY players and force their client to relaunch,
|
||||
# which is worse than the ghost this reaper exists to remove.
|
||||
# (Caught in review 2026-07-26, before it ever ran on a real night.)
|
||||
#
|
||||
if self.launches_sent >= 2 or not self.eggs_released:
|
||||
return
|
||||
#
|
||||
# So the reaper only runs in the ACTIVE STAGING WINDOW: the egg is out and
|
||||
# we are waiting for ACK/READY, which is exactly when a pod that has gone
|
||||
# quiet is a pod that is gone. A truly half-open socket is caught sooner
|
||||
# and more cheaply by TCP keepalive (set on accept, and the pod sets it on
|
||||
# its own beacon too) -- this deadline is only the backstop for platforms
|
||||
# where the keepalive tunables do not take.
|
||||
#
|
||||
now = time.time()
|
||||
for conn in list(self.console_conns):
|
||||
if not conn.egg_sent:
|
||||
continue # nothing was asked of it yet
|
||||
if now - getattr(conn, "last_seen", now) > DEAD_PEER_SECONDS:
|
||||
if not conn.egg_sent or conn.acked:
|
||||
continue # nothing owed / debt already paid
|
||||
# Measure the debt from EGG SEND, not from the last byte: a pad that
|
||||
# connected early and sat through a long held-egg wait would
|
||||
# otherwise be over the deadline the instant the egg went out.
|
||||
owed_since = max(getattr(conn, "last_seen", now),
|
||||
getattr(conn, "egg_sent_at", now))
|
||||
if now - owed_since > DEAD_PEER_SECONDS:
|
||||
print(f"[relay] console conn {conn.addr[0]}:{conn.addr[1]} "
|
||||
f"REAPED -- silent for "
|
||||
f"{now - conn.last_seen:.0f}s while staging "
|
||||
f"(acked={conn.acked}); it can no longer hold the round "
|
||||
f"open", flush=True)
|
||||
f"REAPED -- egg sent {now - owed_since:.0f}s ago and "
|
||||
f"never ACKed; it can no longer hold the round open",
|
||||
flush=True)
|
||||
self._drop_console(conn)
|
||||
for conn in list(self.game_conns):
|
||||
if conn.host_id is None:
|
||||
continue # beacon / not yet HELLO'd: silent by design
|
||||
if now - getattr(conn, "last_seen", now) > DEAD_PEER_SECONDS:
|
||||
self._drop_game(conn, "silent for %.0fs while staging -- reaped"
|
||||
% (now - conn.last_seen))
|
||||
|
||||
def _tag_of(self, host_id):
|
||||
"""The LIVE roster tag for a host id ('?' if out of range)."""
|
||||
@@ -1057,6 +1059,35 @@ class Relay:
|
||||
start a new round. Reset only the ROUND state -- seats, beacons, prefs
|
||||
and connections are left alone, so whoever is here stays here.
|
||||
"""
|
||||
#
|
||||
# GUARDED ON EVERY CHANNEL (review 2026-07-26). The `launch_at is None`
|
||||
# guard lived only on the LAUNCH-press path, but the ctl `rearm` command,
|
||||
# the stdin reader and the always-lit GUI button all reached here
|
||||
# unconditionally. Mid-mission (launches_sent==2) a re-arm zeroes the
|
||||
# counters, which permanently kills the mission clock AND makes End
|
||||
# Mission print "no mission is running" -- the round becomes
|
||||
# unstoppable except by Stop Session, the exact outcome this feature
|
||||
# exists to eliminate. Mid-pair (launch_at set) it destroys the
|
||||
# RunMission pair and re-releases eggs to mid-launch pods -- the re-arm
|
||||
# storm. Refuse both, loudly.
|
||||
#
|
||||
# "Running" is launches_sent >= 2 AND no StopMission issued yet. NOT
|
||||
# launches_sent alone: after a FINISHED round (clock expiry or End
|
||||
# Mission -> stop_sent True) launches_sent still reads 2 until the
|
||||
# re-arm itself clears it -- refusing on that would resurrect the very
|
||||
# wedge this feature exists to fix (caught by the regression suite when
|
||||
# the review's own proposed guard was applied verbatim).
|
||||
if self.launches_sent >= 2 and not self.stop_sent:
|
||||
print("[relay] RE-ARM refused -- a mission is RUNNING; press "
|
||||
"End Mission first", flush=True)
|
||||
self.launch_requested = False # do not let the press linger
|
||||
return
|
||||
if self.launch_at is not None:
|
||||
print(f"[relay] RE-ARM refused -- a launch sequence is in flight "
|
||||
f"(fires in {max(0.0, self.launch_at - time.time()):.0f}s)",
|
||||
flush=True)
|
||||
self.launch_requested = False
|
||||
return
|
||||
print(f"[relay] RE-ARM for a new round ({why}); "
|
||||
f"previous round state cleared", flush=True)
|
||||
self.launches_sent = 0
|
||||
@@ -1090,6 +1121,12 @@ class Relay:
|
||||
FIRST_GAME_HOST_ID + len(self.roster)))
|
||||
self.round_hold_until = 0 # do not inherit an abort's settle window
|
||||
self._reload_egg_file()
|
||||
# Re-key beacons/prefs to the RESTORED roster (review 2026-07-26): they
|
||||
# were keyed by the previous round's TRIMMED host ids, and the launch
|
||||
# fall-through would reinterpret those against the full roster -- a
|
||||
# departed player's tag minted into round 2's egg, a present player's
|
||||
# trimmed out, and every callsign/mech shifted a slot.
|
||||
self._rekey_seats_to_roster()
|
||||
for c in self.console_conns:
|
||||
c.acked = False # last round's ACK must not count
|
||||
c.egg_sent = False # every pod needs THIS round's egg
|
||||
@@ -1346,7 +1383,19 @@ class Relay:
|
||||
continue
|
||||
host_id = FIRST_GAME_HOST_ID + i
|
||||
if host_id in self.by_host:
|
||||
break # actively PLAYING: not ours
|
||||
# A half-open REGISTERED conn (machine slept, power
|
||||
# cut) squats here until keepalive reaps it (~2 min),
|
||||
# and its own player got ROSTER FULL meanwhile. No
|
||||
# mission running + same source IP = this player's
|
||||
# own ghost: displace it, exactly like a zombie
|
||||
# beacon (review 2026-07-26).
|
||||
ghost = self.by_host[host_id]
|
||||
if (self.launches_sent < 2
|
||||
and ghost.addr[0] == req_ip):
|
||||
self._drop_game(
|
||||
ghost, "displaced by its returning player")
|
||||
else:
|
||||
break # actively PLAYING: not ours
|
||||
old = self.seat_beacons.get(host_id)
|
||||
if old is not None: # zombie duplicate: displace it
|
||||
print(f"[relay] displacing stale beacon on seat "
|
||||
@@ -1459,9 +1508,14 @@ class Relay:
|
||||
# trim remaps seat ids the GUI would light (and, since the roster
|
||||
# display fix, clear) the WRONG row. The relay's roster here is the
|
||||
# live -- possibly trimmed -- one, so this tag is the true identity.
|
||||
# Stash the tag AT REGISTRATION: _drop_game resolves against the
|
||||
# LIVE roster, and a trimmed-round conn dying after a re-arm restored
|
||||
# the full roster would otherwise be blamed on the wrong tag -- and
|
||||
# the tag-trusting GUI would clear the wrong seat (review 2026-07-26).
|
||||
conn.reg_tag = self._tag_of(host_id)
|
||||
print(f"[relay] {conn.name()} REGISTERED "
|
||||
f"({len(self.by_host)}/{len(self.roster)}) "
|
||||
f"tag='{self._tag_of(host_id)}'", flush=True)
|
||||
f"tag='{conn.reg_tag}'", flush=True)
|
||||
if len(self.by_host) >= len(self.roster):
|
||||
self._release_eggs() # fallback; normally already fired
|
||||
# PEER_UP exchange: newcomer learns everyone; everyone learns newcomer.
|
||||
@@ -1550,8 +1604,10 @@ class Relay:
|
||||
def _drop_game(self, conn, why):
|
||||
# tag='...' for registered conns, same reason as the REGISTERED print:
|
||||
# the GUI must not resolve a remapped post-trim hostID positionally.
|
||||
drop_tag = (f" tag='{self._tag_of(conn.host_id)}'"
|
||||
if conn.host_id is not None else "")
|
||||
drop_tag = ""
|
||||
if conn.host_id is not None:
|
||||
_dt = getattr(conn, "reg_tag", None) or self._tag_of(conn.host_id)
|
||||
drop_tag = " tag='%s'" % _dt
|
||||
print(f"[relay] {conn.name()} dropped: {why}{drop_tag}", flush=True)
|
||||
try:
|
||||
self.sel.unregister(conn.sock)
|
||||
@@ -1625,6 +1681,8 @@ class Relay:
|
||||
if not hasattr(self, "_udp_spoof_warned"):
|
||||
self._udp_spoof_warned = set()
|
||||
key = (from_host, endpoint[0])
|
||||
if len(self._udp_spoof_warned) > 256:
|
||||
self._udp_spoof_warned.clear() # bounded under a flood
|
||||
if key not in self._udp_spoof_warned:
|
||||
self._udp_spoof_warned.add(key)
|
||||
print(f"[relay] UDP from {endpoint[0]}:{endpoint[1]} CLAIMS "
|
||||
@@ -1771,6 +1829,16 @@ class Relay:
|
||||
# under the lock so no line is lost or duplicated between
|
||||
# the replay and the live stream
|
||||
with _CONTROL_LOCK:
|
||||
# The GUI adopts its roster from the startup "roster:" line,
|
||||
# which is printed ONCE and ages out of the 400-line history
|
||||
# in ~33 minutes of stats chatter -- an operator AUTHing
|
||||
# late into the night got a permanently blank roster and a
|
||||
# dead LAUNCH button (review 2026-07-26). Re-issue the LIVE
|
||||
# roster in the same adoptable format before the replay.
|
||||
conn.queue_out(
|
||||
(f"[relay] roster: {len(self.roster)} pilot(s) -> "
|
||||
f"hostIDs {sorted(self.expected_ids)}: "
|
||||
f"{self.roster}\n").encode("utf-8", "replace"))
|
||||
conn.queue_out(b"[ctl] --- session history replay ---\n")
|
||||
for past in _CONTROL_HISTORY:
|
||||
conn.queue_out(past)
|
||||
@@ -1879,9 +1947,15 @@ class Relay:
|
||||
return
|
||||
self.stats_at = time.time() + STATS_PERIOD
|
||||
s = self.stats
|
||||
# spoofed rides along only when non-zero: the anti-hijack counter was
|
||||
# invisible (never printed anywhere), so an actual hijack attempt left
|
||||
# no operator-visible trace beyond the one-shot warning line.
|
||||
spoofed = s.get("udp_spoofed", 0)
|
||||
spoof_part = f", spoofed {spoofed}" if spoofed else ""
|
||||
print(f"[relay-stats] tcp rx/tx {s['tcp_rx']}/{s['tcp_tx']} | "
|
||||
f"udp rx/tx {s['udp_rx']}/{s['udp_tx']} "
|
||||
f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}) | "
|
||||
f"(dropped {s['udp_dropped']}, tcp-fallback {s['udp_tcp_fallback']}"
|
||||
f"{spoof_part}) | "
|
||||
f"registered {sorted(self.by_host)} udp-known {sorted(self.udp_endpoint)}",
|
||||
flush=True)
|
||||
|
||||
|
||||
+41
-5
@@ -511,7 +511,8 @@ class Operator(QMainWindow):
|
||||
"Clear the finished round's state and re-open the launcher, "
|
||||
"keeping everyone who is already connected. "
|
||||
"Use this if LAUNCH looks dead after a round instead of "
|
||||
"restarting the session.")
|
||||
"restarting the session. Between rounds only: refused while "
|
||||
"a mission is running or a launch is firing.")
|
||||
self.rearm_btn.clicked.connect(self._rearm_round)
|
||||
self.rearm_btn.setEnabled(False)
|
||||
self.launch_local_btn = QPushButton("Launch local instances")
|
||||
@@ -817,12 +818,35 @@ class Operator(QMainWindow):
|
||||
|
||||
# ------------------------------------------------------------- session --
|
||||
|
||||
def _restore_seat_defaults(self):
|
||||
"""Write every stashed configured (callsign, mech) back into its row and
|
||||
clear the stash. MUST run before anything snapshots or saves the table:
|
||||
Restart Session used to _collect_egg() with a departed walk-up's name
|
||||
still in the cell, baking it into the next session's egg (name= AND the
|
||||
rasterized bitmaps) and then re-capturing it as the seat's 'configured
|
||||
default' -- the reported roster bug returning through the egg file
|
||||
(review 2026-07-26)."""
|
||||
for tag, (callsign, mech) in list(self._seat_defaults.items()):
|
||||
for r in range(self.table.rowCount()):
|
||||
item = self.table.item(r, 0)
|
||||
if item is None or item.text().strip() != tag:
|
||||
continue
|
||||
cell = self.table.item(r, 1)
|
||||
if callsign is not None and cell is not None:
|
||||
cell.setText(callsign)
|
||||
combo = self.table.cellWidget(r, 2)
|
||||
if mech is not None and combo is not None:
|
||||
combo.setCurrentText(mech)
|
||||
break
|
||||
self._seat_defaults.clear()
|
||||
|
||||
def _start_session(self):
|
||||
self.end_sent = False
|
||||
# A session stopped while a seat was OCCUPIED leaves its stash entry
|
||||
# behind (the seat never emptied, so it was never popped); a new
|
||||
# session must not restore last session's snapshot.
|
||||
self._seat_defaults.clear()
|
||||
# behind (the seat never emptied, so it was never popped). Write those
|
||||
# configured values BACK into the cells before _collect_egg can bake a
|
||||
# walk-up's name into the egg, then start clean.
|
||||
self._restore_seat_defaults()
|
||||
relay_host = self.f_relayhost.text().strip()
|
||||
if (self.mode.currentIndex() == 0 and relay_host
|
||||
and relay_host.lower() not in ("localhost", "127.0.0.1")):
|
||||
@@ -916,6 +940,7 @@ class Operator(QMainWindow):
|
||||
self.pod_status.setText("remote link closed")
|
||||
|
||||
def _stop_session(self):
|
||||
self._restore_seat_defaults() # departed walk-up names must not outlive the session
|
||||
if getattr(self, "remote_link", None):
|
||||
link = self.remote_link
|
||||
self.remote_link = None # closed-signal becomes a no-op
|
||||
@@ -1130,8 +1155,16 @@ class Operator(QMainWindow):
|
||||
callsign, mech = info
|
||||
else:
|
||||
# Empty: restore the configured pilot and forget the stash, so
|
||||
# the next player to take this seat re-snapshots.
|
||||
# the next player to take this seat re-snapshots. Restore on
|
||||
# `is not None`: an operator-BLANKED cell is a real configured
|
||||
# value, and skipping it as falsy left the departed name up and
|
||||
# then re-captured it as the default (review 2026-07-26).
|
||||
callsign, mech = self._seat_defaults.pop(tag, (None, None))
|
||||
if callsign is not None and cell is not None and cell.text() != callsign:
|
||||
cell.setText(callsign)
|
||||
if mech is not None and combo is not None and combo.currentText() != mech:
|
||||
combo.setCurrentText(mech)
|
||||
continue
|
||||
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:
|
||||
@@ -1145,6 +1178,9 @@ class Operator(QMainWindow):
|
||||
(self.monitor.ready or (self.monitor.relay_mode and seated > 0))
|
||||
and not self.monitor.launched
|
||||
and have_relay)
|
||||
# Re-arm is refused by the relay mid-mission/mid-pair; grey it too so the
|
||||
# button beside END MISSION does not invite the press (review 2026-07-26).
|
||||
self.rearm_btn.setEnabled(have_relay and not self.monitor.launched)
|
||||
if self.monitor.stats:
|
||||
self.stats_label.setText(self.monitor.stats)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user