Skip to content

feat(client): turn replay viewer + targeted component tests - #2253

Closed
claytonlin1110 wants to merge 4 commits into
phase-rs:mainfrom
claytonlin1110:feat/turn-replay-viewer
Closed

feat(client): turn replay viewer + targeted component tests#2253
claytonlin1110 wants to merge 4 commits into
phase-rs:mainfrom
claytonlin1110:feat/turn-replay-viewer

Conversation

@claytonlin1110

Copy link
Copy Markdown
Contributor

Summary

  • Players in single-player (AI/local) games can scrub through
    turnCheckpoints via a floating bottom-center control bar.
    While replaying, the board shows the historical state and all
    game dispatches are blocked — fully read-only.
  • Exit restores the exact live state without touching the adapter
    or WASM engine.
  • 59 new passing tests across the replay store, ReplayControls,
    and three previously-untested components.

Test plan

  • Start an AI game, play several turns (≥2 turn checkpoints)
  • Confirm "View history" button appears bottom-center
  • Click it — amber banner + turn markers appear
  • Navigate with arrows and by clicking markers directly
  • Confirm no game actions are possible while replaying
  • Click "Exit replay" — live state and actions restored
  • Confirm bar is absent in online/P2P multiplayer games
  • pnpm vitest run — 59/59 green

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a turn-replay feature for single-player modes, allowing players to scrub through historical turn checkpoints while blocking game actions. It adds the ReplayControls UI component, integrates it into the main game page, updates translation files, and implements state management in gameStore along with extensive unit tests. The review feedback identifies critical issues where live legal actions and spell costs are not preserved upon entering replay mode, which would permanently lock players out of the live game upon exiting. It also points out that the live turn number in the replay banner incorrectly reads from the active historical checkpoint instead of the saved live game state.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +97 to +101
replayMode: boolean;
/** Index into turnCheckpoints currently being previewed. Null when not in replay mode. */
replayIndex: number | null;
/** Live engine state saved before entering replay; restored on exit. */
liveGameState: GameState | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

[CRITICAL] Add store fields to preserve live legal actions and spell costs during replay mode.

Why it matters: These fields are required to stash the live game's interactive state so it can be fully restored when exiting replay mode.

  replayMode: boolean;
  /** Index into turnCheckpoints currently being previewed. Null when not in replay mode. */
  replayIndex: number | null;
  /** Live engine state saved before entering replay; restored on exit. */
  liveGameState: GameState | null;
  liveLegalActions: GameAction[];
  liveLegalActionsByObject: Record<string, GameAction[]>;
  liveSpellCosts: Record<string, ManaCost>;

Comment on lines +188 to +190
replayMode: false,
replayIndex: null,
liveGameState: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

[CRITICAL] Initialize the live state preservation fields in the initial store state.

Why it matters: Ensures the new fields are properly initialized when the store is created.

Suggested change
replayMode: false,
replayIndex: null,
liveGameState: null,
replayMode: false,
replayIndex: null,
liveGameState: null,
liveLegalActions: [],
liveLegalActionsByObject: {},
liveSpellCosts: {},

Comment on lines +405 to +431
enterReplay: (index) => {
const { turnCheckpoints, gameState, replayMode } = get();
if (replayMode || turnCheckpoints.length === 0 || !gameState) return;
const targetIndex = index ?? turnCheckpoints.length - 1;
const clamped = Math.max(0, Math.min(targetIndex, turnCheckpoints.length - 1));
set({
replayMode: true,
replayIndex: clamped,
liveGameState: gameState,
gameState: turnCheckpoints[clamped],
waitingFor: turnCheckpoints[clamped].waiting_for,
legalActions: [],
legalActionsByObject: {},
});
},

exitReplay: () => {
const { replayMode, liveGameState } = get();
if (!replayMode || !liveGameState) return;
set({
replayMode: false,
replayIndex: null,
liveGameState: null,
gameState: liveGameState,
waitingFor: liveGameState.waiting_for,
});
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

[CRITICAL] Stash live legal actions and spell costs on replay entry, and restore them on exit.

Why it matters: Restoring only the gameState and waitingFor leaves the player with empty legal actions and incorrect spell costs, permanently locking them out of playing the live game after exiting replay mode.

    enterReplay: (index) => {
      const { turnCheckpoints, gameState, replayMode, legalActions, legalActionsByObject, spellCosts } = get();
      if (replayMode || turnCheckpoints.length === 0 || !gameState) return;
      const targetIndex = index ?? turnCheckpoints.length - 1;
      const clamped = Math.max(0, Math.min(targetIndex, turnCheckpoints.length - 1));
      set({
        replayMode: true,
        replayIndex: clamped,
        liveGameState: gameState,
        liveLegalActions: legalActions,
        liveLegalActionsByObject: legalActionsByObject,
        liveSpellCosts: spellCosts,
        gameState: turnCheckpoints[clamped],
        waitingFor: turnCheckpoints[clamped].waiting_for,
        legalActions: [],
        legalActionsByObject: {},
        spellCosts: {},
      });
    },

    exitReplay: () => {
      const { replayMode, liveGameState, liveLegalActions, liveLegalActionsByObject, liveSpellCosts } = get();
      if (!replayMode || !liveGameState) return;
      set({
        replayMode: false,
        replayIndex: null,
        liveGameState: null,
        liveLegalActions: [],
        liveLegalActionsByObject: {},
        liveSpellCosts: {},
        gameState: liveGameState,
        waitingFor: liveGameState.waiting_for,
        legalActions: liveLegalActions,
        legalActionsByObject: liveLegalActionsByObject,
        spellCosts: liveSpellCosts,
      });
    },

const turnCheckpoints = useGameStore((s) => s.turnCheckpoints);
const replayMode = useGameStore((s) => s.replayMode);
const replayIndex = useGameStore((s) => s.replayIndex);
const currentTurn = useGameStore((s) => s.gameState?.turn_number ?? null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Incorrect live turn number in replay banner.

Why it matters: Reading turn_number from gameState during replay mode returns the historical checkpoint's turn, resulting in a misleading live turn display (e.g., showing (live: T1) instead of the actual live turn).

Suggested fix: Use liveGameState to retrieve the live turn number when replayMode is active.

Suggested change
const currentTurn = useGameStore((s) => s.gameState?.turn_number ?? null);
const currentTurn = useGameStore((s) => (s.replayMode ? s.liveGameState : s.gameState)?.turn_number ?? null);

@matthewevans

Copy link
Copy Markdown
Member

🤖 Architecture Review (automated)

Verdict: ⚠️ Changes requested

Seam: PASS — Frontend-only; replay state lives in gameStore (the FE store), dispatch is gated there, no game logic leaks into components. Correct layering for a display-only feature.
Idiomatic: PASS — Clean Zustand actions with narrow selectors, memo/useCallback on TurnMarker, i18n keys for all strings, no bool-soup. Replay UI is read-only and reuses existing checkpoint state.
Value: Covers the general turn-replay class (any single-player game with ≥2 checkpoints), not a one-off.
Reconciled with existing reviews: Gemini raised 4 findings (3 CRITICAL + 1 HIGH).

  • CONFIRMED — live-state lockout on exit (Gemini CRITICAL). enterReplay (gameStore.ts) sets legalActions: [] and legalActionsByObject: {}, and exitReplay restores only gameState/waitingFor, never legalActions/legalActionsByObject. Legal actions are populated imperatively — only after adapter.getLegalActions() calls inside initGame/resumeGame/dispatch (gameStore.ts:181,223,248,289,329). There is no reactive re-fetch keyed on gameState, and dispatch is blocked during replay. So after exiting replay on the human's own priority, legalActions/legalActionsByObject stay empty until the engine independently produces a new state (which won't happen while it's the human's turn) → the player is locked out of the live game. Real bug.
  • CONFIRMED — spellCosts not stashed/cleared (Gemini CRITICAL, lower severity than framed). enterReplay clears legalActions/legalActionsByObject but leaves spellCosts untouched, so stale live spell costs persist into the read-only replay view (cosmetic only, since dispatch is blocked). Restoring/clearing it symmetrically is correct but the impact is a display inconsistency, not a lockout — MEDIUM, not CRITICAL.
  • CONFIRMED — wrong live-turn in banner (Gemini HIGH). currentTurn reads s.gameState?.turn_number (ReplayControls.tsx:61); during replay gameState is the checkpoint, so (live: T{currentTurn}) shows the checkpoint turn, not the actual live turn. Fix: read s.replayMode ? s.liveGameState : s.gameState.
  • CONFIRMED (init-state field, Gemini CRITICAL). Once liveLegalActions/liveLegalActionsByObject (and optionally liveSpellCosts) fields are added, they must be initialized in initialState (gameStore.ts:155-170). Straightforward follow-on to the fix above.

Findings

  • [HIGH] client/src/stores/gameStore.ts (enterReplay/exitReplay) — replay entry clears legalActions/legalActionsByObject but exit never restores them, and nothing re-fetches legal actions on exit → human is locked out of the live game after replaying on their own priority. Stash the live legalActions/legalActionsByObject on entry (new store fields) and restore them on exit, per Gemini's suggestion. The simplest equivalent: capture them into liveLegalActions/liveLegalActionsByObject (init them in initialState) and write them back in exitReplay.
  • [MEDIUM] client/src/components/chrome/ReplayControls.tsx:61currentTurn reads the checkpoint turn during replay; the (live: T…) banner is misleading. Use s.replayMode ? s.liveGameState : s.gameState.
  • [MEDIUM] client/src/stores/gameStore.ts (enterReplay) — spellCosts is left stale on entry (asymmetric with legalActions clearing) and never restored. Clear it on entry and restore the stashed live value on exit for consistency (display-only, no lockout).
  • [NIT] client/src/stores/__tests__/gameStore.replay.test.ts — the exitReplay "restores the live game state" test only asserts turn_number/replayMode/replayIndex/liveGameState; it does not assert legalActions are restored, which is exactly why the lockout regression passes CI. Add an assertion that legalActions/legalActionsByObject are restored after exit so the fix is locked in.
  • [NIT] Unused i18n keys: replay.turnLabel and replay.noHistory are added to en/game.json but not referenced in ReplayControls.tsx (which uses inline T${turn} / (live: T${currentTurn})). Either wire them up or drop them (and note the i18n parity gate requires the same keys across all 6 non-English locales).

@matthewevans matthewevans added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jun 4, 2026
@matthewevans

Copy link
Copy Markdown
Member

@claytonlin1110 Hi, and thanks for the contribution! I really appreciate the effort here, and the test coverage is genuinely nice to see.

That said, I'm going to pass on this one. New player-facing UI features like this are something I'd prefer to design and drive myself, since they carry a lot of UX and product decisions that I want to keep a tight grip on, and that's hard to delegate through a PR after the fact.

Where contributions are most valuable right now is the engine and parser work. Those issues are well-scoped, easier to review against a clear correctness bar, and there's a huge amount still open, so that's where I'd love to point your energy. If you grab one of those, I'm very happy to work through it with you.

Closing this for now, but thank you again, genuinely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants