fix(ui): advance turn glow/interactivity to the player to move

`active` was `$derived(gameStore.activeBoards)`, and that getter read the
non-`$state` `#game`. The $derived captured no reactive dependency, so the
glowing/interactive boards froze on North's NW+NE after move 1: the next
player couldn't act, clicks on their boards did nothing, and selections fired
on unexpected boards — the reported "turns", "what piece is selected", and
"mirrored movements" symptoms were all this one stale-derived bug.

Fix: derive `active` from the reactive `view.currentPlayer`; remove the
footgun `activeBoards` getter so nothing re-reads `#game` reactively.

Add scripts/smoke.py: Playwright end-to-end test driving the real app through
a full N->S->E->W round (engine vitest can't see Svelte reactivity wiring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude (duplicate_chess)
2026-06-26 09:36:59 -04:00
parent 294a786336
commit a44b4de717
4 changed files with 96 additions and 7 deletions
+3
View File
@@ -29,3 +29,6 @@ dist-ssr
.superpowers/ .superpowers/
*.tsbuildinfo *.tsbuildinfo
GITEA_API.md GITEA_API.md
# Backups (global safety rule keeps these locally; never commit them)
.backup/
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""End-to-end smoke test driving the real app in a browser.
Engine vitest covers move logic; this covers the Svelte reactivity wiring that
vitest can't see — specifically the turn glow / interactivity advancing player
to player (the bug where `active` froze on North after move 1).
Boots `vite` itself, drives chromium via Playwright, asserts a full N->S->E->W
round plays and each player's two boards light up in turn. Self-contained:
python3 scripts/smoke.py
Needs system chromium + the `playwright` python package. Exits non-zero on failure.
"""
import subprocess, sys, time, urllib.request, os, signal
from playwright.sync_api import sync_playwright
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
URL = "http://localhost:5177/duplicate/"
BID = ["NW", "NE", "SW", "SE"]
# (player, board to act on, from-square, to-square) — one legal opening per player.
ROUND = [("N", "NW", "e2", "e4"), ("S", "SW", "d2", "d4"),
("E", "NE", "e7", "e5"), ("W", "SW", "d7", "d5")]
EXPECT_ACTIVE = {"N": {"NW", "NE"}, "S": {"SW", "SE"},
"E": {"NE", "SE"}, "W": {"NW", "SW"}}
def wait_server(timeout=30):
for _ in range(timeout * 2):
try:
urllib.request.urlopen(URL, timeout=1); return True
except Exception:
time.sleep(0.5)
return False
def active_boards(pg):
return {BID[i] for i, bd in enumerate(pg.query_selector_all(".board"))
if "active" in (bd.get_attribute("class") or "")}
def piece_at(pg, board, sq):
bi = BID.index(board)
span = pg.locator(".board").nth(bi).locator(f'button[aria-label="{sq}"] span.pc')
return span.inner_text() if span.count() else None
def click(pg, board, sq):
pg.locator(".board").nth(BID.index(board)).locator(f'button[aria-label="{sq}"]').click()
pg.wait_for_timeout(80)
def main():
vite = subprocess.Popen(["pnpm", "dev", "--port", "5177"], cwd=ROOT,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
try:
assert wait_server(), "vite did not come up"
with sync_playwright() as p:
b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])
pg = b.new_page(viewport={"width": 1400, "height": 1000})
pg.goto(URL, wait_until="networkidle"); pg.wait_for_timeout(300)
for player, board, frm, to in ROUND:
got = active_boards(pg)
assert got == EXPECT_ACTIVE[player], \
f"{player}'s turn: expected active {EXPECT_ACTIVE[player]}, got {got}"
other = next(x for x in EXPECT_ACTIVE[player] if x != board)
click(pg, board, frm)
click(pg, board, to)
# move must apply IDENTICALLY on both of the player's boards
for bd in (board, other):
assert piece_at(pg, bd, frm) is None, f"{player} {bd} {frm} not vacated"
assert piece_at(pg, bd, to) is not None, f"{player} {bd} {to} empty after move"
print(f"OK {player}: {frm}{to} applied on {board}+{other}, turn advanced")
assert active_boards(pg) == EXPECT_ACTIVE["N"], "did not cycle back to North"
print("OK full N->S->E->W round cycled back to North")
b.close()
print("\nSMOKE PASS")
finally:
os.killpg(os.getpgid(vite.pid), signal.SIGTERM)
if __name__ == "__main__":
try:
main()
except AssertionError as e:
print(f"\nSMOKE FAIL: {e}"); sys.exit(1)
+4 -1
View File
@@ -15,7 +15,10 @@
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE']; const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
let view = $derived(gameStore.view); let view = $derived(gameStore.view);
let active = $derived(gameStore.activeBoards); // Derive from the reactive `view`, NOT the store's #game getter: #game is
// deliberately not a $state proxy, so a $derived reading it never recomputes
// and the turn glow/interactivity froze on North after move 1.
let active = $derived(PLAYER_BOARDS[view.currentPlayer]);
/** Ghost squares for a given board. */ /** Ghost squares for a given board. */
function ghostsFor(id: BoardId): Square[] { function ghostsFor(id: BoardId): Square[] {
-6
View File
@@ -3,7 +3,6 @@ import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '.
import { ghosts } from '../../engine/ghosts'; import { ghosts } from '../../engine/ghosts';
import { evaluateStatus } from '../../engine/endgame'; import { evaluateStatus } from '../../engine/endgame';
import { serialize, deserialize } from '../../engine/notation'; import { serialize, deserialize } from '../../engine/notation';
import { PLAYER_BOARDS } from '../../engine/boards';
import type { import type {
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus, BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
} from '../../engine/types'; } from '../../engine/types';
@@ -54,11 +53,6 @@ class GameStore {
return this.scrubPly !== null; return this.scrubPly !== null;
} }
/** Which boards belong to the player to move (for the turn glow). */
get activeBoards(): [BoardId, BoardId] {
return PLAYER_BOARDS[this.#game.currentPlayer];
}
/** Grab a piece: must be the current player's turn and a live (non-scrub) view. */ /** Grab a piece: must be the current player's turn and a live (non-scrub) view. */
select(square: Square): void { select(square: Square): void {
if (this.isScrubbing) return; if (this.isScrubbing) return;