From ab1ee5a70eeebc4d1648c3e76d63f7217bd446a9 Mon Sep 17 00:00:00 2001 From: vladmesh Date: Mon, 29 Jun 2026 17:50:59 +0300 Subject: [PATCH 1/6] sprint 020 phase 3 task 1: spectator-listener primitive in GameSession --- src/dnd_simulator/service/session.py | 35 +++++++++++- tests/unit/test_session_lifecycle.py | 85 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index 3e9c6b44..7582eb75 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -268,6 +268,9 @@ class GameSession: _round_thread: threading.Thread | None = field(default=None, init=False, repr=False) _player_brain: PlayerBrain | None = field(default=None, init=False, repr=False) _listeners: list[SessionEventListener] = field(default_factory=list, init=False, repr=False) + # Read-only observers (DM/admin/future spectators). Receive the broadcast, never drive + # the round and never count toward the "session empty" lifecycle decision. + _spectators: list[SessionEventListener] = field(default_factory=list, init=False, repr=False) _last_turn_msg: dict[str, Any] | None = field(default=None, init=False, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) _on_empty: Callable[[GameSession], None] | None = field(default=None, init=False, repr=False) @@ -304,6 +307,15 @@ def _bind_session_context(self) -> None: """Ensure session_id is bound in contextvars for all log calls.""" structlog.contextvars.bind_contextvars(session_id=self.session_id) + def has_player_listeners(self) -> bool: + """Whether any player (round-driving) listener is connected. + + Single source of truth for the "session empty" decision: spectators are + excluded, so a session watched only by spectators counts as empty. Lock-free + read (atomic under the GIL); callers needing a consistent snapshot hold ``_lock``. + """ + return bool(self._listeners) + def add_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() with self._lock: @@ -311,6 +323,23 @@ def add_listener(self, listener: SessionEventListener) -> None: count = len(self._listeners) logger.info("add_listener", listener_count=count) + def add_spectator(self, listener: SessionEventListener) -> None: + """Register a read-only observer. Receives the broadcast; never drives lifecycle.""" + self._bind_session_context() + with self._lock: + self._spectators.append(listener) + count = len(self._spectators) + logger.info("add_spectator", spectator_count=count) + + def remove_spectator(self, listener: SessionEventListener) -> None: + """Remove a read-only observer. Never stops the round, never fires ``_on_empty``.""" + self._bind_session_context() + with self._lock: + with contextlib.suppress(ValueError): + self._spectators.remove(listener) + count = len(self._spectators) + logger.info("remove_spectator", spectator_count=count) + def get_last_turn_msg(self) -> dict[str, Any] | None: """Return the last turn message for replay by the caller.""" return self._last_turn_msg @@ -323,7 +352,7 @@ def remove_listener(self, listener: SessionEventListener) -> None: with contextlib.suppress(ValueError): self._listeners.remove(listener) count = len(self._listeners) - if not self._listeners: + if not self.has_player_listeners(): is_empty = True if self._round is not None: stop_round = True @@ -334,9 +363,9 @@ def remove_listener(self, listener: SessionEventListener) -> None: self._on_empty(self) def _fire(self, method: str, *args: object) -> None: - """Call a method on all listeners, swallowing individual errors.""" + """Call a method on all listeners and spectators, swallowing individual errors.""" with self._lock: - listeners = list(self._listeners) + listeners = self._listeners + self._spectators for listener in listeners: try: getattr(listener, method)(*args) diff --git a/tests/unit/test_session_lifecycle.py b/tests/unit/test_session_lifecycle.py index a965bd5e..731d418d 100644 --- a/tests/unit/test_session_lifecycle.py +++ b/tests/unit/test_session_lifecycle.py @@ -173,6 +173,91 @@ def test_remove_unregistered_listener_is_noop(self) -> None: assert session._listeners == [registered] +class TestSpectatorListener: + """Read-only spectators (Sprint 020 phase 3): receive the broadcast, never drive lifecycle.""" + + def test_spectator_receives_broadcast(self) -> None: + """A spectator gets the same events fired to player listeners.""" + session = _session() + player = RecordingListener() + spectator = RecordingListener() + session.add_listener(player) + session.add_spectator(spectator) + + msg = {"x": 1} + session._fire("on_turn", msg) + session._fire("on_action_result", msg) + + assert player.calls == [("on_turn", (msg,)), ("on_action_result", (msg,))] + assert spectator.calls == [("on_turn", (msg,)), ("on_action_result", (msg,))] + + def test_spectator_added_after_player_receives_later_events(self) -> None: + """Joining mid-stream, a spectator gets subsequent events (not the ones it missed).""" + session = _session() + player = RecordingListener() + session.add_listener(player) + session._fire("on_turn", {"first": 1}) + + spectator = RecordingListener() + session.add_spectator(spectator) + session._fire("on_turn", {"second": 2}) + + assert spectator.calls == [("on_turn", ({"second": 2},))] + assert player.calls == [("on_turn", ({"first": 1},)), ("on_turn", ({"second": 2},))] + + def test_spectator_alone_is_player_empty(self) -> None: + """A session with only spectators counts as player-empty (lifecycle keys on players).""" + session = _session() + session.add_spectator(RecordingListener()) + assert session.has_player_listeners() is False + + def test_player_present_has_player_listeners(self) -> None: + session = _session() + session.add_listener(RecordingListener()) + assert session.has_player_listeners() is True + + def test_remove_only_spectator_does_not_fire_on_empty(self) -> None: + """Removing a spectator never triggers the empty handler, even with no players present.""" + session = _session() + spectator = RecordingListener() + session.add_spectator(spectator) + + fired: list[GameSession] = [] + session._on_empty = lambda s: fired.append(s) + + session.remove_spectator(spectator) + + assert fired == [] + + def test_remove_spectator_keeps_player_in_broadcast(self) -> None: + """After a spectator leaves, the remaining player listener still gets events.""" + session = _session() + player = RecordingListener() + spectator = RecordingListener() + session.add_listener(player) + session.add_spectator(spectator) + + session.remove_spectator(spectator) + session._fire("on_turn", {"z": 9}) + + assert player.calls == [("on_turn", ({"z": 9},))] + + def test_last_player_leaving_with_spectator_present_fires_on_empty(self) -> None: + """The session is player-empty when the last player goes, even if a spectator remains.""" + session = _session() + player = RecordingListener() + spectator = RecordingListener() + session.add_listener(player) + session.add_spectator(spectator) + + seen: list[GameSession] = [] + session._on_empty = lambda s: seen.append(s) + + session.remove_listener(player) + + assert seen == [session] + + # --------------------------------------------------------------------------- # Submit guards (no round running) # --------------------------------------------------------------------------- From 500b5066b79a86a5a9f28fcad0b6769b66f57b0f Mon Sep 17 00:00:00 2001 From: vladmesh Date: Mon, 29 Jun 2026 18:00:54 +0300 Subject: [PATCH 2/6] sprint 020 phase 3 task 2: disconnect grace-period (debounce evict) --- src/dnd_simulator/service/session.py | 58 ++++++++++++++-- tests/integration/test_websocket.py | 31 +++++++++ tests/unit/test_session_lifecycle.py | 100 +++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 13 deletions(-) diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index 7582eb75..db8f501a 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -3,6 +3,7 @@ import contextlib import contextvars import dataclasses +import os import threading from collections.abc import Callable from dataclasses import dataclass, field @@ -31,6 +32,11 @@ logger = structlog.get_logger(domain="session") +# Grace window before an emptied session is stopped + evicted. A disconnect+reconnect +# inside this window (StrictMode remount, network blip) cancels the evict. Configurable +# for ops/tests; overridable per-session via ``GameSession._evict_grace_seconds``. +_DEFAULT_EVICT_GRACE_SECONDS = float(os.getenv("DND_EVICT_GRACE_SECONDS", "1.5")) + # --------------------------------------------------------------------------- # Listener protocol — transports implement this to receive game events @@ -274,6 +280,8 @@ class GameSession: _last_turn_msg: dict[str, Any] | None = field(default=None, init=False, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) _on_empty: Callable[[GameSession], None] | None = field(default=None, init=False, repr=False) + _evict_timer: threading.Timer | None = field(default=None, init=False, repr=False) + _evict_grace_seconds: float = field(default=_DEFAULT_EVICT_GRACE_SECONDS, init=False, repr=False) # --------------------------------------------------------------------------- # Player queries @@ -319,6 +327,8 @@ def has_player_listeners(self) -> bool: def add_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() with self._lock: + # A (re)connecting player cancels any pending evict — this is the grace window. + self._cancel_evict_check() self._listeners.append(listener) count = len(self._listeners) logger.info("add_listener", listener_count=count) @@ -346,20 +356,53 @@ def get_last_turn_msg(self) -> dict[str, Any] | None: def remove_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() - stop_round = False - is_empty = False + scheduled = False with self._lock: with contextlib.suppress(ValueError): self._listeners.remove(listener) count = len(self._listeners) if not self.has_player_listeners(): - is_empty = True - if self._round is not None: - stop_round = True - logger.info("remove_listener", listener_count=count, stop_round=stop_round) + # Defer stop+evict: a quick disconnect+reconnect (StrictMode remount, + # network blip) must not flash the session out of the registry. The + # deferred check (_run_evict_check) re-verifies emptiness before acting. + self._schedule_evict_check() + scheduled = True + logger.info("remove_listener", listener_count=count, scheduled_evict=scheduled) + + def _schedule_evict_check(self) -> None: + """Arm the deferred empty-session check. Caller holds ``_lock``.""" + if self._evict_timer is not None: + self._evict_timer.cancel() + timer = threading.Timer(self._evict_grace_seconds, self._run_evict_check) + timer.daemon = True + self._evict_timer = timer + timer.start() + + def _cancel_evict_check(self) -> None: + """Cancel any pending deferred evict. Caller holds ``_lock``.""" + if self._evict_timer is not None: + self._evict_timer.cancel() + self._evict_timer = None + + def _run_evict_check(self) -> None: + """Timer callback: stop the round and fire ``_on_empty`` only if still player-empty. + + Runs on a Timer thread after the grace window. A reconnect inside the window + adds a player and cancels this timer, so emptiness is re-verified under the lock. + """ + self._bind_session_context() + stop_round = False + with self._lock: + self._evict_timer = None + if self.has_player_listeners(): + logger.info("evict_check_skipped_reconnected") + return + if self._round is not None: + stop_round = True + logger.info("evict_check_firing", stop_round=stop_round) if stop_round: self.stop_round() - if is_empty and self._on_empty is not None: + if self._on_empty is not None: self._on_empty(self) def _fire(self, method: str, *args: object) -> None: @@ -507,6 +550,7 @@ def stop_round(self) -> None: self._bind_session_context() logger.info("stop_round") with self._lock: + self._cancel_evict_check() game_round = self._round brain = self._player_brain thread = self._round_thread diff --git a/tests/integration/test_websocket.py b/tests/integration/test_websocket.py index 98f107d2..d530c7a6 100644 --- a/tests/integration/test_websocket.py +++ b/tests/integration/test_websocket.py @@ -137,6 +137,37 @@ def test_reconnect_replays_last_turn(self, ws_arena: tuple[str, str, str]) -> No assert "awareness" in msg2 sock2.close() + def test_quick_reconnect_keeps_session_live(self, ws_arena: tuple[str, str, str]) -> None: + """A disconnect+reconnect inside the grace window keeps the session usable. + + The precise no-evict mechanics are unit-tested (test_session_lifecycle); here we + exercise the real grace timer end-to-end: after a fast reconnect the player still + gets a turn and the round is live enough to accept an action. + """ + ws_base, sid, pid = ws_arena + + sock1 = ws_connect(ws_base, sid, pid) + assert _recv_until(sock1, "turn") is not None + sock1.close() + + # Reconnect well within the default grace window (~1.5s) — no sleep. + sock2 = ws_connect(ws_base, sid, pid) + try: + msg = _recv_until(sock2, "turn") + if msg is None: + return # combat may have ended; reconnect itself succeeded + ws_send_action(sock2, "end_turn") + # Round is alive: it advances and emits a follow-up event. + for _ in range(15): + try: + nxt = ws_recv(sock2) + except ws_lib.WebSocketConnectionClosedException: + break + if nxt["type"] in ("turn", "round_result", "action_result"): + break + finally: + sock2.close() + def test_invalid_session_returns_error(self, ws_arena: tuple[str, str, str]) -> None: """Connecting to nonexistent session returns error and closes.""" ws_base, _, _ = ws_arena diff --git a/tests/unit/test_session_lifecycle.py b/tests/unit/test_session_lifecycle.py index 731d418d..f25a063e 100644 --- a/tests/unit/test_session_lifecycle.py +++ b/tests/unit/test_session_lifecycle.py @@ -144,9 +144,11 @@ def test_raising_listener_does_not_block_others(self) -> None: class TestEmptyListeners: - def test_removing_last_listener_fires_on_empty(self) -> None: - """With no round running, removing the last listener calls _on_empty once.""" + def test_removing_last_listener_schedules_deferred_evict(self) -> None: + """Grace-period (Sprint 020 phase 3): removing the last player arms a deferred + check rather than firing _on_empty synchronously. A reconnect can still cancel it.""" session = _session() + session._evict_grace_seconds = 3600 # don't let the real timer fire mid-test listener = RecordingListener() session.add_listener(listener) @@ -155,7 +157,9 @@ def test_removing_last_listener_fires_on_empty(self) -> None: session.remove_listener(listener) - assert seen == [session] + assert seen == [] # not fired synchronously + assert session._evict_timer is not None # deferred check armed + session._evict_timer.cancel() def test_remove_unregistered_listener_is_noop(self) -> None: """Removing a listener that was never registered does not raise or drop others.""" @@ -242,9 +246,12 @@ def test_remove_spectator_keeps_player_in_broadcast(self) -> None: assert player.calls == [("on_turn", ({"z": 9},))] - def test_last_player_leaving_with_spectator_present_fires_on_empty(self) -> None: - """The session is player-empty when the last player goes, even if a spectator remains.""" + def test_last_player_leaving_with_spectator_present_is_player_empty(self) -> None: + """The session is player-empty when the last player goes, even if a spectator remains: + remove_listener arms the deferred evict (grace-period), the predicate reports empty. + The firing path is covered by TestEvictGracePeriod.""" session = _session() + session._evict_grace_seconds = 3600 # don't let the real timer fire mid-test player = RecordingListener() spectator = RecordingListener() session.add_listener(player) @@ -255,7 +262,88 @@ def test_last_player_leaving_with_spectator_present_fires_on_empty(self) -> None session.remove_listener(player) - assert seen == [session] + assert session.has_player_listeners() is False + assert seen == [] # deferred, not synchronous + assert session._evict_timer is not None + session._evict_timer.cancel() + + +class TestEvictGracePeriod: + """Disconnect debounce (Sprint 020 phase 3): defer stop+evict, reconnect cancels. + + The timer fires on a background thread after the grace window; these tests set a + huge window so it never fires mid-test and drive _run_evict_check directly. + """ + + def test_last_player_leaving_schedules_not_synchronous(self) -> None: + session = _session() + session._evict_grace_seconds = 3600 + player = RecordingListener() + session.add_listener(player) + fired: list[GameSession] = [] + session._on_empty = lambda s: fired.append(s) + + session.remove_listener(player) + + assert fired == [] # deferred, not synchronous + assert session._evict_timer is not None + session._evict_timer.cancel() + + def test_deferred_check_evicts_when_still_empty(self) -> None: + """When the window elapses with the session still player-empty, _on_empty fires once.""" + session = _session() + session._evict_grace_seconds = 3600 + player = RecordingListener() + session.add_listener(player) + fired: list[GameSession] = [] + session._on_empty = lambda s: fired.append(s) + + session.remove_listener(player) + assert session._evict_timer is not None + session._evict_timer.cancel() # we drive the check by hand + + session._run_evict_check() + + assert fired == [session] + + def test_reconnect_within_window_cancels_evict(self) -> None: + """A player reconnecting inside the window cancels the pending evict.""" + session = _session() + session._evict_grace_seconds = 3600 + p1 = RecordingListener() + session.add_listener(p1) + fired: list[GameSession] = [] + session._on_empty = lambda s: fired.append(s) + + session.remove_listener(p1) + assert session._evict_timer is not None + + p2 = RecordingListener() + session.add_listener(p2) # reconnect cancels the timer + assert session._evict_timer is None + + # A stale check that runs anyway must no-op — a player is present. + session._run_evict_check() + assert fired == [] + + def test_lingering_spectator_does_not_save_abandoned_session(self) -> None: + """Only a spectator left → still player-empty → the deferred check evicts.""" + session = _session() + session._evict_grace_seconds = 3600 + player = RecordingListener() + spectator = RecordingListener() + session.add_listener(player) + session.add_spectator(spectator) + fired: list[GameSession] = [] + session._on_empty = lambda s: fired.append(s) + + session.remove_listener(player) + assert session._evict_timer is not None + session._evict_timer.cancel() + + session._run_evict_check() + + assert fired == [session] # --------------------------------------------------------------------------- From 71be4db7daf5c5bb45120d589ac075772ba05935 Mon Sep 17 00:00:00 2001 From: vladmesh Date: Mon, 29 Jun 2026 18:15:57 +0300 Subject: [PATCH 3/6] sprint 020 phase 3 task 3: spectator WS endpoint (?spectate=true) --- src/dnd_simulator/adapters/api/routes_ws.py | 72 ++++++++++- tests/integration/test_websocket.py | 129 ++++++++++++++++++++ 2 files changed, 197 insertions(+), 4 deletions(-) diff --git a/src/dnd_simulator/adapters/api/routes_ws.py b/src/dnd_simulator/adapters/api/routes_ws.py index 47c9ed1e..6a68400d 100644 --- a/src/dnd_simulator/adapters/api/routes_ws.py +++ b/src/dnd_simulator/adapters/api/routes_ws.py @@ -28,6 +28,7 @@ from dnd_simulator.adapters.api.deps import get_service from dnd_simulator.i18n import _ from dnd_simulator.service.action_parsing import ActionParseError, parse_action +from dnd_simulator.service.session import GameSession logger = structlog.get_logger(domain="transport") @@ -80,12 +81,70 @@ def on_game_over(self) -> None: # --------------------------------------------------------------------------- +async def _run_spectator(ws: WebSocket, session: GameSession, session_id: str) -> None: + """Read-only observe loop for a `?spectate=true` connection. + + Registers a spectator listener (never drives the round, never counts toward the + session-empty decision), replays the last turn on connect, and rejects any + `action`/`reaction` the client sends. The receive loop exists only to detect + disconnect; on exit the spectator is removed without ever evicting the session. + """ + listener = WsEventListener(ws, asyncio.get_running_loop()) + + # Replay last turn so a mid-session joiner sees current state immediately. + last_turn = session.get_last_turn_msg() + if last_turn is not None: + await ws.send_json(last_turn) + + session.add_spectator(listener) + + # Rate limiting: token bucket (same shape as the player path) + rl_budget = 20.0 + rl_last = time.monotonic() + rl_max_burst = 20.0 + rl_per_sec = 5.0 + + try: + while True: + raw = await ws.receive_text() + + now = time.monotonic() + rl_budget = min(rl_max_burst, rl_budget + (now - rl_last) * rl_per_sec) + rl_last = now + if rl_budget < 1.0: + await ws.send_json({"type": "error", "message": _("Rate limited, slow down")}) + continue + rl_budget -= 1.0 + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await ws.send_json({"type": "error", "message": _("Invalid JSON")}) + continue + msg_type = msg.get("type") + + if msg_type in ("action", "reaction"): + await ws.send_json({"type": "error", "message": _("Spectators cannot submit actions")}) + else: + await ws.send_json({"type": "error", "message": _("Unknown message type: {}").format(msg_type)}) + + except WebSocketDisconnect: + logger.info("ws_spectator_disconnected", session_id=session_id) + except Exception: + logger.exception("ws_spectator_error", session_id=session_id) + finally: + # Symmetric with the player path's to_thread, though remove_spectator never + # joins the round thread — a spectator leaving never stops the round. + await asyncio.to_thread(session.remove_spectator, listener) + + @router.websocket("/api/ws/{session_id}") -async def websocket_game(ws: WebSocket, session_id: str, player_id: str | None = None) -> None: +async def websocket_game(ws: WebSocket, session_id: str, player_id: str | None = None, spectate: bool = False) -> None: """WebSocket game loop for a session. Thin bridge: validates session, registers as listener, forwards actions. - Round lifecycle is owned by GameSession. + Round lifecycle is owned by GameSession. With `?spectate=true` the connection + is a read-only observer (no player, no start_round, actions rejected). """ # Origin check allowed_raw = os.getenv("WS_ALLOWED_ORIGINS", "") @@ -97,10 +156,10 @@ async def websocket_game(ws: WebSocket, session_id: str, player_id: str | None = return await ws.accept() - logger.info("ws_connected", session_id=session_id, player_id=player_id) + logger.info("ws_connected", session_id=session_id, player_id=player_id, spectate=spectate) service = get_service() - # Validate session and player + # Validate session try: session = service.get_session(session_id) except ValueError: @@ -108,6 +167,11 @@ async def websocket_game(ws: WebSocket, session_id: str, player_id: str | None = await ws.close(code=4004, reason="session_not_found") return + # Spectator branch: read-only, no player resolution, no start_round. + if spectate: + await _run_spectator(ws, session, session_id) + return + player = session.get_player(player_id) if player is None: await ws.send_json({"type": "error", "message": _("No player in session")}) diff --git a/tests/integration/test_websocket.py b/tests/integration/test_websocket.py index d530c7a6..95cb2522 100644 --- a/tests/integration/test_websocket.py +++ b/tests/integration/test_websocket.py @@ -103,6 +103,14 @@ def _recv_until(sock: ws_lib.WebSocket, target_type: str, max_msgs: int = 80) -> return None +def _spectate_connect(ws_base_url: str, session_id: str, player_id: str | None = None) -> ws_lib.WebSocket: + """Open a read-only spectator WS (`?spectate=true`). player_id is optional.""" + url = f"{ws_base_url}/{session_id}?spectate=true" + if player_id: + url += f"&player_id={player_id}" + return ws_lib.create_connection(url, timeout=10) + + # ── Connection & first turn ─────────────────────────────────────────── @@ -182,6 +190,127 @@ def test_invalid_session_returns_error(self, ws_arena: tuple[str, str, str]) -> sock.close() +# ── Spectator (read-only observe) ───────────────────────────────────── + + +class TestSpectator: + """Phase 3 task 3: `?spectate=true` registers a read-only observer. + + A spectator receives the same player's-eye broadcast as the player, never + drives the round, cannot submit actions, and never evicts the session. + """ + + def test_no_player_id_needed(self, ws_arena: tuple[str, str, str]) -> None: + """`?spectate=true` with no player_id connects (no 4004 no_player) and works. + + The spectator never calls start_round, so the session stays dormant until a + player joins; once one does, the spectator receives the broadcast — proving it + registered correctly without a player_id. + """ + ws_base, sid, pid = ws_arena + spec = _spectate_connect(ws_base, sid) # no player_id + try: + assert spec.connected, "spectator socket should stay open without a player_id" + player = ws_connect(ws_base, sid, pid) + try: + assert _recv_until(spec, "turn") is not None, "spectator got no broadcast after player joined" + finally: + player.close() + finally: + spec.close() + + def test_join_replays_last_turn(self, ws_arena: tuple[str, str, str]) -> None: + """A spectator joining a running session receives the cached last turn on connect.""" + ws_base, sid, pid = ws_arena + player = ws_connect(ws_base, sid, pid) + try: + assert _recv_until(player, "turn") is not None # round running, last-turn cached + spec = _spectate_connect(ws_base, sid) + try: + # No send from the spectator — the replay arrives on connect. + assert _recv_until(spec, "turn") is not None, "spectator never got the replayed turn" + finally: + spec.close() + finally: + player.close() + + def test_receives_event_stream(self, ws_arena: tuple[str, str, str]) -> None: + """A player action broadcasts to the spectator read-only (it sent nothing to get it).""" + ws_base, sid, pid = ws_arena + player = ws_connect(ws_base, sid, pid) + try: + assert _recv_until(player, "turn") is not None + spec = _spectate_connect(ws_base, sid) + try: + assert _recv_until(spec, "turn") is not None # consume the connect replay + + ws_send_action(player, "end_turn") + + got = None + for _ in range(60): + msg = ws_recv(spec) + if msg["type"] in ("action_result", "round_result", "turn"): + got = msg + break + assert got is not None, "spectator never received a broadcast after the player acted" + finally: + spec.close() + finally: + player.close() + + def test_cannot_submit(self, ws_arena: tuple[str, str, str]) -> None: + """An action on the spectator socket is rejected before dispatch (no state change).""" + ws_base, sid, pid = ws_arena + player = ws_connect(ws_base, sid, pid) + try: + assert _recv_until(player, "turn") is not None + spec = _spectate_connect(ws_base, sid) + try: + assert _recv_until(spec, "turn") is not None # consume replay + + ws_send_action(spec, "attack", target_id="razor") + + err = _recv_until(spec, "error") + assert err is not None, "spectator action should be rejected with an error" + assert "Spectators cannot submit actions" in err["message"] + finally: + spec.close() + finally: + player.close() + + def test_disconnect_does_not_evict(self, ws_arena: tuple[str, str, str], _urls: tuple[str, str, str]) -> None: + """A spectator leaving never evicts: the player keeps playing and the session stays listed.""" + api, _, _ = _urls + ws_base, sid, pid = ws_arena + player = ws_connect(ws_base, sid, pid) + try: + assert _recv_until(player, "turn") is not None + spec = _spectate_connect(ws_base, sid) + assert _recv_until(spec, "turn") is not None + spec.close() + time.sleep(0.3) # let the server process the spectator disconnect + + # Session still in the registry (no eviction from a spectator leaving). + resp = requests.get(f"{api}/sessions", timeout=10) + resp.raise_for_status() + assert sid in {s["session_id"] for s in resp.json()} + + # Strong signal: the round is still live — the player socket keeps advancing. + ws_send_action(player, "end_turn") + got = None + for _ in range(20): + try: + msg = ws_recv(player) + except ws_lib.WebSocketConnectionClosedException: + break + if msg["type"] in ("turn", "round_result", "action_result"): + got = msg + break + assert got is not None, "player socket stopped working after the spectator left" + finally: + player.close() + + # ── Peaceful flow ───────────────────────────────────────────────────── From c6ef0e3afb6f38fd46744dd399b498b948d4341e Mon Sep 17 00:00:00 2001 From: vladmesh Date: Mon, 29 Jun 2026 18:57:46 +0300 Subject: [PATCH 4/6] sprint 020 phase 3 task 4: frontend live observe stream (spectator WS feed) SessionLiveFeed opens a dedicated spectator WsClient (?spectate=true) and renders a read-only event feed in a new SessionView 'live' tab for DM/admin. WsClient.connect gains a { playerId?, spectate? } options form. Broke the latent wsClient<->gameStore import cycle (surfaced by importing WsClient from a component): wsClient now reads identity via loadIdentity() from identitySlice (type-only gameStore import) instead of useGameStore.getState(). --- .../src/components/master/SessionLiveFeed.tsx | 71 ++++++++ .../src/components/master/SessionView.tsx | 5 +- .../__tests__/SessionView.live.test.tsx | 170 ++++++++++++++++++ frontend/src/i18n/locales/en/master.json | 2 + frontend/src/i18n/locales/ru/master.json | 2 + frontend/src/transport/wsClient.ts | 20 ++- 6 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/master/SessionLiveFeed.tsx create mode 100644 frontend/src/components/master/__tests__/SessionView.live.test.tsx diff --git a/frontend/src/components/master/SessionLiveFeed.tsx b/frontend/src/components/master/SessionLiveFeed.tsx new file mode 100644 index 00000000..18a5101c --- /dev/null +++ b/frontend/src/components/master/SessionLiveFeed.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { WsClient } from "@/transport/wsClient" +import type { ServerMessage } from "@/types/ws" + +interface FeedItem { + id: number + description: string + eventType: string +} + +// Read-only live event feed for a master observing a session. Opens a dedicated +// spectator WebSocket (never the player singleton) and renders incoming +// `PerceivedEvent.description` lines, newest last. +export function SessionLiveFeed({ sessionId }: { sessionId: string }) { + const { t } = useTranslation(["master"]) + const [items, setItems] = useState([]) + const scrollRef = useRef(null) + const seqRef = useRef(0) + + useEffect(() => { + const client = new WsClient() + const unsub = client.onMessage((msg: ServerMessage) => { + if (msg.type !== "turn" && msg.type !== "action_result" && msg.type !== "round_result") return + const events = msg.events ?? [] + if (events.length === 0) return + setItems((prev) => [ + ...prev, + ...events.map((e) => ({ + id: seqRef.current++, + description: e.description, + eventType: e.event_type, + })), + ]) + }) + client.connect(sessionId, { spectate: true }) + return () => { + unsub() + client.disconnect() + } + }, [sessionId]) + + // Auto-scroll to newest line. + useEffect(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, [items.length]) + + return ( +
+ {items.length === 0 ? ( +
{t("master:live_empty")}
+ ) : ( +
    + {items.map((item) => ( +
  • + + {item.eventType} + + {item.description} +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/master/SessionView.tsx b/frontend/src/components/master/SessionView.tsx index 5a0397d0..2aa987de 100644 --- a/frontend/src/components/master/SessionView.tsx +++ b/frontend/src/components/master/SessionView.tsx @@ -6,12 +6,13 @@ import type { WorldStateResponse } from "@/types/api" import { Button } from "@/components/ui/button" import { WorldOverview } from "./WorldOverview" import { CreatureList } from "./CreatureList" +import { SessionLiveFeed } from "./SessionLiveFeed" import { TimeControl } from "./TimeControl" import { SavesPanel } from "./SavesPanel" import { Skeleton } from "@/components/ui/skeleton" import { ArrowLeft } from "lucide-react" -type Tab = "world" | "creatures" | "time" | "saves" +type Tab = "world" | "creatures" | "live" | "time" | "saves" export function SessionView() { const { sessionId } = useParams<{ sessionId: string }>() @@ -46,6 +47,7 @@ export function SessionView() { const tabs: { key: Tab; label: string }[] = [ { key: "world", label: t("master:tab_world") }, { key: "creatures", label: t("master:tab_creatures") }, + { key: "live", label: t("master:tab_live") }, { key: "time", label: t("master:tab_time") }, { key: "saves", label: t("master:tab_saves") }, ] @@ -100,6 +102,7 @@ export function SessionView() { {tab === "creatures" && ( )} + {tab === "live" && } {tab === "time" && ( )} diff --git a/frontend/src/components/master/__tests__/SessionView.live.test.tsx b/frontend/src/components/master/__tests__/SessionView.live.test.tsx new file mode 100644 index 00000000..c105b8de --- /dev/null +++ b/frontend/src/components/master/__tests__/SessionView.live.test.tsx @@ -0,0 +1,170 @@ +import { render, screen, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { MemoryRouter, Routes, Route } from "react-router" +import "@/i18n" + +// Each new WsClient registers itself so the test can drive its message stream. +// Defined inside vi.hoisted so the mock factory (hoisted above imports) can reach it. +const { wsInstances, MockWsClient } = vi.hoisted(() => { + const instances: Cls[] = [] + class Cls { + connect = vi.fn() + disconnect = vi.fn() + onStatus = vi.fn(() => vi.fn()) + getStatus = vi.fn(() => "disconnected") + send = vi.fn() + handlers = new Set<(m: unknown) => void>() + onMessage = vi.fn((h: (m: unknown) => void) => { + this.handlers.add(h) + return () => this.handlers.delete(h) + }) + emit(m: unknown) { + this.handlers.forEach((h) => h(m)) + } + constructor() { + instances.push(this) + } + } + return { wsInstances: instances, MockWsClient: Cls } +}) + +type MockWsClient = InstanceType + +vi.mock("@/transport/wsClient", () => ({ + WsClient: MockWsClient, + wsClient: new MockWsClient(), +})) + +vi.mock("@/transport/apiClient", () => ({ + api: { + master: { + getSession: vi.fn(), + getCreatures: vi.fn(), + deleteCreature: vi.fn(), + setBrain: vi.fn(), + }, + }, +})) + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } })) + +import { SessionView } from "../SessionView" +import { api } from "@/transport/apiClient" +import type { WorldStateResponse } from "@/types/api" + +const mockApi = vi.mocked(api.master) + +const emptyWorld: WorldStateResponse = { + session_id: "sess-1", + time: "Day 1", + regions: [], + nations: [], + settlements: [], + entities: [], +} + +function renderView() { + return render( + + + } /> + + , + ) +} + +function lastWs(): MockWsClient { + return wsInstances[wsInstances.length - 1] +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + wsInstances.length = 0 + mockApi.getSession.mockResolvedValue(emptyWorld) + mockApi.getCreatures.mockResolvedValue([]) +}) + +describe("SessionView live observe feed", () => { + it("renders live events without action controls", async () => { + const user = userEvent.setup() + renderView() + + await screen.findByText(/Day 1/) + await user.click(screen.getByRole("button", { name: /live/i })) + + const ws = lastWs() + expect(ws.connect).toHaveBeenCalledWith("sess-1", { spectate: true }) + + act(() => { + ws.emit({ + type: "turn", + events: [ + { description: "Goblin attacks Aria", event_type: "entity_attack" }, + { description: "Aria moves north", event_type: "entity_move" }, + ], + }) + }) + + expect(await screen.findByText("Goblin attacks Aria")).toBeInTheDocument() + expect(screen.getByText("Aria moves north")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /^attack$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /end turn/i })).not.toBeInTheDocument() + }) + + it("keeps existing write tabs while adding the live feed", async () => { + const user = userEvent.setup() + renderView() + + await screen.findByText(/Day 1/) + expect(screen.getByRole("button", { name: /^time$/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^saves$/i })).toBeInTheDocument() + + await user.click(screen.getByRole("button", { name: /live/i })) + const ws = lastWs() + act(() => { + ws.emit({ type: "turn", events: [{ description: "Orc roars", event_type: "custom" }] }) + }) + + expect(await screen.findByText("Orc roars")).toBeInTheDocument() + }) + + it("accumulates events across turn and action_result messages in arrival order", async () => { + const user = userEvent.setup() + renderView() + + await screen.findByText(/Day 1/) + await user.click(screen.getByRole("button", { name: /live/i })) + const ws = lastWs() + + act(() => { + ws.emit({ type: "turn", events: [{ description: "First event", event_type: "custom" }] }) + }) + act(() => { + ws.emit({ + type: "action_result", + events: [{ description: "Second event", event_type: "custom" }], + }) + }) + + expect(await screen.findByText("First event")).toBeInTheDocument() + expect(screen.getByText("Second event")).toBeInTheDocument() + const feed = screen.getByTestId("live-feed") + const text = feed.textContent ?? "" + expect(text.indexOf("First event")).toBeLessThan(text.indexOf("Second event")) + }) + + it("connects the spectator socket on mount and disconnects on unmount", async () => { + const user = userEvent.setup() + const { unmount } = renderView() + + await screen.findByText(/Day 1/) + await user.click(screen.getByRole("button", { name: /live/i })) + const ws = lastWs() + expect(ws.connect).toHaveBeenCalledWith("sess-1", { spectate: true }) + + unmount() + expect(ws.disconnect).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/i18n/locales/en/master.json b/frontend/src/i18n/locales/en/master.json index 0f69f68b..047814be 100644 --- a/frontend/src/i18n/locales/en/master.json +++ b/frontend/src/i18n/locales/en/master.json @@ -19,8 +19,10 @@ "tab_world": "World", "tab_creatures": "Creatures", + "tab_live": "Live", "tab_time": "Time", "tab_saves": "Saves", + "live_empty": "Waiting for events…", "regions": "Regions", "nations": "Nations", diff --git a/frontend/src/i18n/locales/ru/master.json b/frontend/src/i18n/locales/ru/master.json index 11192543..59046e62 100644 --- a/frontend/src/i18n/locales/ru/master.json +++ b/frontend/src/i18n/locales/ru/master.json @@ -19,8 +19,10 @@ "tab_world": "Мир", "tab_creatures": "Существа", + "tab_live": "Лента", "tab_time": "Время", "tab_saves": "Сохранения", + "live_empty": "Ожидание событий…", "regions": "Регионы", "nations": "Нации", diff --git a/frontend/src/transport/wsClient.ts b/frontend/src/transport/wsClient.ts index 781d65f1..76756a5c 100644 --- a/frontend/src/transport/wsClient.ts +++ b/frontend/src/transport/wsClient.ts @@ -12,6 +12,7 @@ export class WsClient { private ws: WebSocket | null = null private sessionId: string | null = null private playerId: string | null = null + private spectate = false private messageHandlers = new Set() private statusHandlers = new Set() private status: WsStatus = "disconnected" @@ -23,10 +24,17 @@ export class WsClient { return this.status } - connect(sessionId: string, playerId?: string): void { + // Player screens pass a player id. Master live feed passes a spectator option. + connect(sessionId: string, opts?: string | { playerId?: string; spectate?: boolean }): void { this.intentionalClose = false this.sessionId = sessionId - this.playerId = playerId ?? null + if (typeof opts === "string" || opts === undefined) { + this.playerId = opts ?? null + this.spectate = false + } else { + this.playerId = opts.playerId ?? null + this.spectate = opts.spectate ?? false + } this.retryMs = INITIAL_RETRY_MS this.doConnect() } @@ -78,9 +86,11 @@ export class WsClient { const proto = location.protocol === "https:" ? "wss:" : "ws:" let url = `${proto}//${location.host}/api/ws/${this.sessionId}` - if (this.playerId) { - url += `?player_id=${encodeURIComponent(this.playerId)}` - } + const params = new URLSearchParams() + if (this.playerId) params.set("player_id", this.playerId) + if (this.spectate) params.set("spectate", "true") + const qs = params.toString() + if (qs) url += `?${qs}` const ws = new WebSocket(url) this.ws = ws From 514a7b61abc047c88c99f65bb2281a59f435bb70 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:19:16 +0300 Subject: [PATCH 5/6] chore: adapt debounce spectator transfer --- docs/BACKLOG.md | 4 ++-- docs/STATUS.md | 1 + frontend/src/transport/wsClient.ts | 4 ++-- frontend/vite.config.ts | 1 + src/dnd_simulator/adapters/api/routes_ws.py | 2 +- src/dnd_simulator/service/session.py | 2 +- tests/integration/test_websocket.py | 14 +++++++------- tests/unit/test_session_lifecycle.py | 4 ++-- 8 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f3ff8913..7ffe774b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -85,7 +85,7 @@ - [ ] **should** `periodic-autosave-scheduler` — фоновый asyncio таск в FastAPI lifespan каждые ~2 мин вызывает `autosave_all_sessions()`; cancel на shutdown перед финальным autosave. Дополняет per-action и shutdown автосейв. Повышен could→should: «мир заморожен на полушаге» (simulation-core) требует надёжного автосейва - [ ] **should?** `wait-no-fastforward-with-npc` — **требует проверки: баг или медленно-но-корректно.** `wait` не делает fast-forward, когда в локации игрока сидит активный rule-NPC — вместо прыжка к `wake_at` раунд тикает по 6 c, и управление к игроку возвращается только через ~600 раундов (1 час игрового времени). Ожидание (playbook 2.3): время сдвигается на 1 час, ход быстро возвращается. **Repro (E2E sprint 020 phase 2):** мир «Долина Мечей», сессия 283d42a2, локация «Солёный Якорь» (`silverport_city_tavern`) с co-located rule-NPC «Марта»; игрок Grimwald QA (Fighter L1) жмёт «Ждать» → action bar застревает на «Ожидание хода…». Бэклог: `wait_sleep` hours=1, `wake_at=46326855600`, но `round_end` показывает `game_time` сдвинутым лишь на 6 c (`second=6`) — fast-forward «нет активных существ → прыжок к ближайшему wake_at» не срабатывает, т.к. Марта остаётся активной. Гипотеза: при `wait` игрок получает `wake_at` и перестаёт быть anchor'ом, значит co-located NPC тоже должен уйти в dormant и включить fast-forward — но не уходит. Проверить `ActivationManager.update_activation` / anchor-логику и путь fast-forward в `Round.run_loop`. NB: наблюдалось после evict→reconnect (см. `session-disconnect-debounce`), но поведение `wait` от этого не зависит. Смежно: `test-gap-ws-fastforward`. По simulation-core гипотеза корректна: расписание-NPC без якоря рядом не должен оставаться активным; тот самый путь, который перепишут `intents`/`anchor-as-property`, — но проверить/починить стоит уже сейчас - [ ] **could** `saved-session-accumulation` — Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке -- [ ] **should** `session-disconnect-debounce` — быстрый disconnect+reconnect (React StrictMode remount, сетевой блип) гонит лишний evict: `remove_listener` останавливает раунд и `_on_session_empty` выселяет сессию из реестра. Симптом GAME OVER устранён в post-audit sprint 018 (раунд-луп больше не шлёт `on_game_over` при административном `stop()` — `Round.is_stopped`), но сессия всё равно выселяется и живёт орфаном на reconnect-WS (прогресс может не попасть в реестровый autosave; reload поднимает старый autosave). Простой re-check `has_listeners()` в `_on_session_empty` пробовали и откатили: на module-scoped WS-тестах он сохранял сессию вместо evict→reload-reset, и арена-бой накапливался до `game_over` (5 падений `test_websocket.py` в CI, timing-зависимо). Полноценный фикс — grace-period: при опустошении не выселять сразу, а отложенно (1–2с через `threading.Timer`) перепроверить пустоту и только тогда `stop_round`+evict; reconnect внутри окна отменяет (+ переработать module-scoped арена-фикстуру, чтобы не зависеть от evict-reset). Прод (без StrictMode-двумаунта) почти не задет, поэтому not-must +- [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` ставит grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`) и повторно проверяет пустоту перед `stop_round` + evict; reconnect в окне отменяет timer. Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`. ## DevOps / Infra @@ -103,7 +103,7 @@ - [x] `battle-map-configs-not-wired` — ~~`battle_map_configs` из `regions.yaml` не передаётся в `EntitiesLayer` при создании сессии в `game_service.py`. Все combat maps дефолтят в 60×60~~ FIXED Sprint 018 (verified Sprint 019 phase 3): `game_service.py:171-183` строит `battle_map_configs` через `_flatten_region_defaults(load_battle_maps(...))` и передаёт в `EntitiesLayer` - [x] `player-character-no-attacks` — ~~`POST /api/player/sessions/{id}/character` не принимает `attacks`; персонаж дерётся кулаками (1 урон)~~ FIXED Sprint 013 char-creation (verified Sprint 019 phase 3): `create_player` грузит `starting_equipment` оружие, игрок бьёт через `get_weapon_attack()`. Поле `attacks` в `CreatePlayerRequest` вестигиальное для игрока (raw `attacks` — путь монстра/спавна) - [x] `look-action-i18n-hardcode` — ~~`_cmd_look` в GameService хардкодит строки «Terrain:»/«Weather:» вместо `_()`~~ OBSOLETE Sprint 019 phase 3: `_cmd_look` удалён в раннем рефакторе, строк «Terrain:»/«Weather:» в `service/` нет (остались только устаревшие msgid в `.po`, помечены obsolete в phase 3 task 1) -- [x] `player-xp-not-persisted` — ~~XP и `level_up_available` игрока не переживают save/reload через современный путь~~ FIXED Sprint 020 phase 1 task 1 (сериализационная половина): `experience`/`level_up_available` в `to_full_save_data()` + `PlayerContent`/`_to_player`, round-trip regression-тест. Dev-симптом с WS StrictMode evict→restore остаётся в `session-disconnect-debounce` (транспортная половина) +- [x] `player-xp-not-persisted`: ~~XP и `level_up_available` игрока не переживают save/reload через современный путь~~ FIXED Sprint 020 phase 1 task 1 (сериализационная половина): `experience`/`level_up_available` в `to_full_save_data()` + `PlayerContent`/`_to_player`, round-trip regression-тест. Dev-симптом с WS StrictMode evict→restore закрыт в `session-disconnect-debounce` (транспортная половина) - [ ] **could** `spawn-role-freetext-enum` — мастерский Spawn Creature диалог (`CreatureForm`) рендерит Role как свободный textbox, но бэкенд `NpcContent.role` — enum (`commoner`/`blacksmith`/`tavern_keeper`/`guard`/`merchant`/`farmer`/`gladiator`). Пустой/произвольный role → HTTP 400 с сырым Pydantic-сообщением прямо в диалоге (E2E sprint 019 phase 1). Сделать Role дропдауном `NpcRole` (и/или маппить ошибку в дружелюбный i18n-тост). Сосед `corpse-nearby-actions` по теме visible-gaps - [ ] **should** `action-bar-unequip-i18n` — кнопки снятия экипировки в боевом action bar показывают сырые ID и английские описания (E2E sprint 020 phase 1). Оружие: метка «Снять» (RU ✓), но описание «Put away your equipped weapon. You will fight with fists.» (EN ✗). Броня/щит: метки `unequip_armor`/`unequip_shield` — сырые `ActionType`-строки (EN ✗), описания тоже английские. Только weapon-unequip переведён. Фикс: добавить `unequip_armor`/`unequip_shield` в таблицу локализации фронта рядом с `unequip`; перевести описания в `.po`. NB: Sprint 020 phase 3 task 6 сохранил 12 ActionType (реестр сделан бэкенд-internal, коллапс отложен в `equip-action-collapse`) — эти метки не меняются сейчас; при будущем коллапсе синхронизировать оба айтема одним PR - [ ] **could** `second-wind-zero-heal` — Second Wind показывает «восстанавливаешь 0 ОЗ» когда игрок уже при максимальных HP (E2E sprint 020 phase 1). Сообщение корректно с механической точки зрения (ресурс потрачен, лечение = 0), но выглядит как баг. Подавлять или заменять на «ты уже в полном здравии» когда `healed == 0`. diff --git a/docs/STATUS.md b/docs/STATUS.md index 57ba6e41..d4bca6e8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -13,6 +13,7 @@ No active sprint. ## Recent activity (non-sprint) +- 2026-07-10: перенесены ценные фичи из `sprint/020-control-interfaces`: disconnect grace-period закрыл `session-disconnect-debounce`, spectator-listener добавил read-only WS `?spectate=true` и live-вкладку в master session view. - 2026-07-10 — Sprint 020 thermo-sweep закрыт: integration 154 passed, post-audit E2E smoke 5/5, audit triaged, PR opened to main. - 2026-07-04 — брейншторм [simulation-core](brainstorms/simulation-core.md): консенсус-модель времени/активности/внутреннего я/лестницы детализации. VISION.md переписан, BACKLOG реструктурирован (секция Simulation Core, поглощённые/переформулированные айтемы, чекбоксы фаз 1-2 спринта 020), ROADMAP Planned обновлён, указатели-актуализации в старых брейнштормах. - 2026-06-20 — CORS origins сделаны конфигурируемыми (`CORS_ALLOWED_ORIGINS`); Docker base-image запинен по digest. diff --git a/frontend/src/transport/wsClient.ts b/frontend/src/transport/wsClient.ts index 76756a5c..2f441f4f 100644 --- a/frontend/src/transport/wsClient.ts +++ b/frontend/src/transport/wsClient.ts @@ -114,9 +114,9 @@ export class WsClient { } ws.onclose = (ev) => { - if (this.ws !== ws) return // stale — a newer WS replaced us + if (this.ws !== ws) return // stale; a newer WS replaced us this.ws = null - // 4004 = session not found or no player — don't reconnect + // 4004 = session not found or no player; don't reconnect if (ev.code === 4004) { this.setStatus("error") return diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c0fe3e46..58d603ef 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], + fileParallelism: false, css: false, }, }) diff --git a/src/dnd_simulator/adapters/api/routes_ws.py b/src/dnd_simulator/adapters/api/routes_ws.py index 6a68400d..4afbc1b8 100644 --- a/src/dnd_simulator/adapters/api/routes_ws.py +++ b/src/dnd_simulator/adapters/api/routes_ws.py @@ -134,7 +134,7 @@ async def _run_spectator(ws: WebSocket, session: GameSession, session_id: str) - logger.exception("ws_spectator_error", session_id=session_id) finally: # Symmetric with the player path's to_thread, though remove_spectator never - # joins the round thread — a spectator leaving never stops the round. + # joins the round thread because a spectator leaving never stops the round. await asyncio.to_thread(session.remove_spectator, listener) diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index db8f501a..bb546504 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -327,7 +327,7 @@ def has_player_listeners(self) -> bool: def add_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() with self._lock: - # A (re)connecting player cancels any pending evict — this is the grace window. + # A (re)connecting player cancels any pending evict during the grace window. self._cancel_evict_check() self._listeners.append(listener) count = len(self._listeners) diff --git a/tests/integration/test_websocket.py b/tests/integration/test_websocket.py index 95cb2522..6f5e2e60 100644 --- a/tests/integration/test_websocket.py +++ b/tests/integration/test_websocket.py @@ -33,8 +33,8 @@ def _urls(backend_url: str) -> tuple[str, str, str]: # round loop advances combat on a background thread whenever a WS is connected. A shared # module session accumulated combat across tests until the player died, leaving the session # in a terminal `game_over` state that broke every later test (flaky: depended on how many -# rounds elapsed per connect). A fresh session per test starts combat at round 1 — player -# and all enemies alive — which is exactly the ongoing-combat state these tests assert on. +# rounds elapsed per connect). A fresh session per test starts combat at round 1 with the +# player and all enemies alive, which is exactly the ongoing-combat state these tests assert on. @pytest.fixture def ws_arena(_urls: tuple[str, str, str]) -> Iterator[tuple[str, str, str]]: """Fresh arena session per test. Yields (ws_base_url, session_id, player_id).""" @@ -129,7 +129,7 @@ def test_connect_and_receive_turn(self, ws_arena: tuple[str, str, str]) -> None: sock.close() def test_reconnect_replays_last_turn(self, ws_arena: tuple[str, str, str]) -> None: - """Disconnect and reconnect — should receive last turn message.""" + """Disconnect and reconnect should receive last turn message.""" ws_base, sid, pid = ws_arena sock1 = ws_connect(ws_base, sid, pid) @@ -158,7 +158,7 @@ def test_quick_reconnect_keeps_session_live(self, ws_arena: tuple[str, str, str] assert _recv_until(sock1, "turn") is not None sock1.close() - # Reconnect well within the default grace window (~1.5s) — no sleep. + # Reconnect well within the default grace window (~1.5s), no sleep. sock2 = ws_connect(ws_base, sid, pid) try: msg = _recv_until(sock2, "turn") @@ -204,7 +204,7 @@ def test_no_player_id_needed(self, ws_arena: tuple[str, str, str]) -> None: """`?spectate=true` with no player_id connects (no 4004 no_player) and works. The spectator never calls start_round, so the session stays dormant until a - player joins; once one does, the spectator receives the broadcast — proving it + player joins; once one does, the spectator receives the broadcast, proving it registered correctly without a player_id. """ ws_base, sid, pid = ws_arena @@ -227,7 +227,7 @@ def test_join_replays_last_turn(self, ws_arena: tuple[str, str, str]) -> None: assert _recv_until(player, "turn") is not None # round running, last-turn cached spec = _spectate_connect(ws_base, sid) try: - # No send from the spectator — the replay arrives on connect. + # No send from the spectator; the replay arrives on connect. assert _recv_until(spec, "turn") is not None, "spectator never got the replayed turn" finally: spec.close() @@ -295,7 +295,7 @@ def test_disconnect_does_not_evict(self, ws_arena: tuple[str, str, str], _urls: resp.raise_for_status() assert sid in {s["session_id"] for s in resp.json()} - # Strong signal: the round is still live — the player socket keeps advancing. + # Strong signal: the round is still live because the player socket keeps advancing. ws_send_action(player, "end_turn") got = None for _ in range(20): diff --git a/tests/unit/test_session_lifecycle.py b/tests/unit/test_session_lifecycle.py index f25a063e..aca3d87c 100644 --- a/tests/unit/test_session_lifecycle.py +++ b/tests/unit/test_session_lifecycle.py @@ -322,12 +322,12 @@ def test_reconnect_within_window_cancels_evict(self) -> None: session.add_listener(p2) # reconnect cancels the timer assert session._evict_timer is None - # A stale check that runs anyway must no-op — a player is present. + # A stale check that runs anyway must no-op because a player is present. session._run_evict_check() assert fired == [] def test_lingering_spectator_does_not_save_abandoned_session(self) -> None: - """Only a spectator left → still player-empty → the deferred check evicts.""" + """Only a spectator left means still player-empty, so the deferred check evicts.""" session = _session() session._evict_grace_seconds = 3600 player = RecordingListener() From 0e13407966c067b22a50a062629d44f82831fd88 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:13:04 +0300 Subject: [PATCH 6/6] fix: pause round immediately on player-empty disconnect, defer only evict The grace-period debounce deferred BOTH stop_round and eviction. Deferring stop_round left a player-less round loop advancing NPC turns during the grace window: for a real player a network blip silently costs combat turns, and because every session draws from one process-global dice RNG, overlapping orphaned rounds shifted the RNG nondeterministically. That is what flaked test_player_state_xp::test_rest_status_updated_after_kill in CI (1 failed / 159 passed): at the failure moment several arena/OA sessions whose sockets had already closed were still ticking combat (visible as consecutive_failures_end_turn in the backend log), draining the shared seeded RNG so the combat_test player's attacks no longer killed the 1-HP dummy within the message budget, and no xp_gained event was observed. Fix: on the last player leaving, stop the round at once (restoring pre-PR behavior) and defer only the registry eviction via the grace timer. A reconnect inside the window cancels the eviction and restarts the round through the player WS path's idempotent start_round. This is a code fix, not a test change: the behavior (round advancing with nobody connected) was the regression. Also: BACKLOG vitest-load-flakes -> [x] mitigated (fileParallelism:false, already in this PR; note the proper long fix is per-file isolation, not whole-run serialization). Three consecutive make test-integration runs green (160 passed, ~11.5s each); make check-backend green (2381 passed). --- docs/BACKLOG.md | 4 ++-- src/dnd_simulator/service/session.py | 26 +++++++++++++++++------- tests/unit/test_session_lifecycle.py | 30 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 7ffe774b..00ac3953 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -85,7 +85,7 @@ - [ ] **should** `periodic-autosave-scheduler` — фоновый asyncio таск в FastAPI lifespan каждые ~2 мин вызывает `autosave_all_sessions()`; cancel на shutdown перед финальным autosave. Дополняет per-action и shutdown автосейв. Повышен could→should: «мир заморожен на полушаге» (simulation-core) требует надёжного автосейва - [ ] **should?** `wait-no-fastforward-with-npc` — **требует проверки: баг или медленно-но-корректно.** `wait` не делает fast-forward, когда в локации игрока сидит активный rule-NPC — вместо прыжка к `wake_at` раунд тикает по 6 c, и управление к игроку возвращается только через ~600 раундов (1 час игрового времени). Ожидание (playbook 2.3): время сдвигается на 1 час, ход быстро возвращается. **Repro (E2E sprint 020 phase 2):** мир «Долина Мечей», сессия 283d42a2, локация «Солёный Якорь» (`silverport_city_tavern`) с co-located rule-NPC «Марта»; игрок Grimwald QA (Fighter L1) жмёт «Ждать» → action bar застревает на «Ожидание хода…». Бэклог: `wait_sleep` hours=1, `wake_at=46326855600`, но `round_end` показывает `game_time` сдвинутым лишь на 6 c (`second=6`) — fast-forward «нет активных существ → прыжок к ближайшему wake_at» не срабатывает, т.к. Марта остаётся активной. Гипотеза: при `wait` игрок получает `wake_at` и перестаёт быть anchor'ом, значит co-located NPC тоже должен уйти в dormant и включить fast-forward — но не уходит. Проверить `ActivationManager.update_activation` / anchor-логику и путь fast-forward в `Round.run_loop`. NB: наблюдалось после evict→reconnect (см. `session-disconnect-debounce`), но поведение `wait` от этого не зависит. Смежно: `test-gap-ws-fastforward`. По simulation-core гипотеза корректна: расписание-NPC без якоря рядом не должен оставаться активным; тот самый путь, который перепишут `intents`/`anchor-as-property`, — но проверить/починить стоит уже сейчас - [ ] **could** `saved-session-accumulation` — Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке -- [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` ставит grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`) и повторно проверяет пустоту перед `stop_round` + evict; reconnect в окне отменяет timer. Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`. +- [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` **сразу** ставит раунд на паузу (`stop_round`), а откладывает только выселение из реестра: ставится grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`), reconnect в окне отменяет timer, а вернувшийся игрок перезапускает раунд через `start_round`. Раньше откладывались И stop_round, И evict — из-за этого player-less раунд-луп продолжал крутить ходы NPC в grace-окне (при сетевом блипе игрок молча терял боевые ходы), а поскольку все сессии тянут один процесс-глобальный RNG костей, пересекающиеся «осиротевшие» раунды делали seed-зависимые integration-тесты недетерминированными (`test_player_state_xp::test_rest_status_updated_after_kill` флакал в CI). Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`. ## DevOps / Infra @@ -110,7 +110,7 @@ - [x] `combat-log-i18n-gaps` — ~~при дефолтном `DND_LANGUAGE=ru` боевой лог наполовину английский~~ FIXED: movement-ошибки обёрнуты в `_()` Sprint 019 phase 3; остальные хендлеры (items/equipment/trade/action_surge/loot/combat) + прогон каталога и RU-перевод Sprint 020 phase 1 task 4. Остаточные фронтовые метки — в `action-bar-unequip-i18n` - [x] `sneak-attack-faction-check` — ~~SA ally-adjacency считала союзником любое живое существо в 5ft без учёта фракции~~ FIXED Sprint 011/014: ally detection через faction relations - [x] `flaky-initiative-test` — ~~`test_second_attack_does_not_reroll_initiative` падал рандомно~~ FIXED: AC=30 чтобы атаки всегда мазали, c2 не удаляется из turn_order -- [ ] **should** `vitest-load-flakes` — 6 vitest-тестов в 4 файлах падают в полном `npx vitest run` под нагрузкой, но зелёные в изоляции (baseline-прогон orca-воркера на чистом main, 2026-07-04): `CreatureForm.test.tsx` (2: HP current/max), `EntityListEditor.test.tsx` (2: create/edit panel), `CreatureList.test.tsx` (1: brain toggle toast), `LevelUpModal.test.tsx` (1: Paladin fighting styles). Все тесты тяжёлые (7-17с) — похоже на waitFor/timeout под параллельной нагрузкой. Та же семья, что `flaky-schemaform-ref-select`. Фикс: поднять testTimeout/waitFor бюджеты или ограничить параллелизм vitest для тяжёлых файлов +- [x] **should** `vitest-load-flakes` — mitigated 2026-07-10: `fileParallelism: false` в `frontend/vite.config.ts` (в этом PR) сериализует запуск тестовых файлов, снимая параллельную нагрузку, из-за которой падали 6 тестов в 4 файлах (`CreatureForm.test.tsx` HP current/max, `EntityListEditor.test.tsx` create/edit panel, `CreatureList.test.tsx` brain toggle toast, `LevelUpModal.test.tsx` Paladin fighting styles). Корневая причина — изоляция тестов под нагрузкой (waitFor/timeout при параллельном исполнении тяжёлых 7-17с файлов); правильный долгий фикс — ревизия изоляции тяжёлых файлов (а не только сериализация всего прогона). Та же семья, что `flaky-schemaform-ref-select` - [ ] **could** `tsc-build-test-looseness` — `tsc -b` даёт 28 ошибок в тест/конфиг-файлах фронта, при зелёном CI-гейте `tsc --noEmit` (baseline orca-воркера 2026-07-04). Не гейтится, но это реальная типовая расхлябанность тестов. Подтянуть или задокументировать, почему гейт именно `--noEmit` - [ ] **could** `flaky-schemaform-ref-select` — `frontend/src/components/master/__tests__/SchemaForm.test.tsx > renders ref field as select with fetched options` флапает в полном `npx vitest run` (ждёт 3 option, видит 1), но зелёный при изоляции файла и на повторе. Похоже на гонку мока fetch ref-опций / async-рендера select. Замечен на Sprint 018 phase 3 (бэкенд-only коммит, влиять не мог). Стабилизировать ожидание опций (`findBy`/`waitFor`) или изолировать fetch-мок между тестами diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index bb546504..a750d14c 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -356,18 +356,30 @@ def get_last_turn_msg(self) -> dict[str, Any] | None: def remove_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() - scheduled = False + stop_round = False with self._lock: with contextlib.suppress(ValueError): self._listeners.remove(listener) count = len(self._listeners) - if not self.has_player_listeners(): - # Defer stop+evict: a quick disconnect+reconnect (StrictMode remount, - # network blip) must not flash the session out of the registry. The - # deferred check (_run_evict_check) re-verifies emptiness before acting. + player_empty = not self.has_player_listeners() + if player_empty and self._round is not None: + stop_round = True + logger.info("remove_listener", listener_count=count, player_empty=player_empty, stop_round=stop_round) + if stop_round: + # Pause the simulation immediately when no player drives it. A lingering round + # thread would keep advancing NPC turns during the grace window: for a real + # player a network blip would silently cost them combat turns, and because all + # sessions share one process-global dice RNG, overlapping player-less rounds make + # seeded integration tests nondeterministic. stop_round() cancels any stale timer, + # so the evict is (re)armed after it returns. + self.stop_round() + if player_empty: + # Defer only the registry eviction: a quick disconnect+reconnect (StrictMode + # remount, network blip) must not flash the session out of the registry, and the + # reconnecting player restarts the round via start_round. The deferred check + # (_run_evict_check) re-verifies emptiness before firing _on_empty. + with self._lock: self._schedule_evict_check() - scheduled = True - logger.info("remove_listener", listener_count=count, scheduled_evict=scheduled) def _schedule_evict_check(self) -> None: """Arm the deferred empty-session check. Caller holds ``_lock``.""" diff --git a/tests/unit/test_session_lifecycle.py b/tests/unit/test_session_lifecycle.py index aca3d87c..f978bfc4 100644 --- a/tests/unit/test_session_lifecycle.py +++ b/tests/unit/test_session_lifecycle.py @@ -488,6 +488,36 @@ def test_submit_after_start_does_not_raise(self, session_with_player: Any) -> No session.submit_player_action(Action(name=ActionType.END_TURN)) +class TestDisconnectStopsRound: + """Disconnect debounce (refined): the last player leaving pauses the round *immediately* + while deferring only the registry eviction. + + A round left running with no player would keep advancing NPC turns during the grace + window — silently costing a blipped player combat turns — and, because every session + draws from one process-global dice RNG, overlapping player-less rounds make seeded + integration tests nondeterministic (observed: test_player_state_xp flaked in CI).""" + + def test_last_listener_disconnect_stops_round_now_defers_evict(self, session_with_player: Any) -> None: + session, player = session_with_player + session._evict_grace_seconds = 3600 # keep the real timer from firing mid-test + fired: list[GameSession] = [] + session._on_empty = lambda s: fired.append(s) + listener = RecordingListener() + session.add_listener(listener) + session.start_round(player) + assert session._round is not None + + session.remove_listener(listener) + + # Round paused at once: no lingering thread advancing NPC turns / draining the RNG. + assert session._round is None + assert session._round_thread is None + # Eviction is still deferred: a quick reconnect can cancel it. + assert fired == [] + assert session._evict_timer is not None + session._evict_timer.cancel() + + class TestStopRound: def test_stop_clears_state_and_joins_thread(self, session_with_player: Any) -> None: session, player = session_with_player