The "Promote pawn" dialog popped for any pawn "moved" toward the last
rank: the commit paths checked piece type + destination rank but never
the pawn's SOURCE rank. With the phantom layer now filling ranks 7-8
with tappable phantom pieces, tapping one (which falls through to the
real-move handler) while a real pawn was armed triggered the dialog for
a move no pawn could make — and for any phantom type, not just pawns.
Root cause: incomplete promotion detection, duplicated in Game.svelte
`onCommit` and the server's `isPromotionRequired`. Replaced with one
shared `isPromotionMove(piece, from, to)` — pawn, from the rank adjacent
to promotion, to the promotion rank, at most one file over — used by
both. 7 unit tests in packages/shared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opponent (black) phantom pieces rendered near-invisible on the dark
--panel background in the palette and Captures panel, and the black
drag-ghost was low-contrast over dark areas. Black piece glyphs in
those three spots now get a light text-shadow outline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-key the phantom-load effect on `loadedFor` (tracks gameId) so it
reloads if the same <Game> instance is reused for a different game
without a remount. Also gate the drag-ghost block behind
`phantomLayerEnabled` for consistency with all other phantom UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phantom pieces are a pointer-only overlay; adding role/tabindex would create
a focusable element with no keyboard equivalent (worse a11y, not better).
The real game remains fully keyboard-operable via the square <button>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add onCancel handler for browser/OS gesture takeover (scroll, palm
rejection) that previously leaked window listeners and left drag stuck.
Extract detach() helper called by onUp, onCancel, and start() for
idempotency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a null-valued entry under a valid square key (d3) to the
'keeps valid entries and drops invalid ones' fixture, proving the
typeof/null guard branch in deserializePhantoms is exercised.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten the local byYou/byOpponent accumulators from the wider
Record<string, number> to PieceTally, matching the return type's fields
and preventing silent invalid-key writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Attempted-move lines (no_such_piece, no_legal_moves, wont_help, illegal_move)
now show "White — " or "Black — " prefix derived from ply parity. Removed
alarm-red err styling; replaced with neutral dim+italic via .entry.attempt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pop intermediate wont_help/illegal_move/no_such_piece/no_legal_moves
announcements produced during the bot's decision cycle before any
broadcast reaches the human opponent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All move-event announcements in translator.ts and all attempted-move
announcements in commit.ts now use audience 'both' so the moderator
panel is a complete shared transcript for both players.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The blind-mode CasualBrain heuristic ignored the moderator's
'<own>_in_check' announcement and scored moves on capture/advance/development
signals uncorrelated with check resolution. chess.js rejected every
non-resolving attempt, BotDriver's RETRY_CAP=5 fired, and the bot resigned.
100-game blind self-play: 100% resignations at avg ply 26.
Fix:
- CasualBrain.detectOwnCheck() scans newAnnouncements for the own-color
in_check tag; when set, heuristicPick() applies a +5000 boost to king
moves so they're tried first. Information stays within the public
moderator vocabulary — no oracle access, view-filter invariant intact.
- RETRY_CAP raised 5 -> 25. Vanilla never hits the cap (chess.js verbose
moves are guaranteed legal); blind needs more budget to find a legal
move through pseudo-legal candidates.
- BotDriver.botResign() now logs '[bot resign]' with gameId/color/mode/ply/
reason/detail. Previously silent — operator had no signal in journald.
Verification (100-game blind Casual-vs-Casual self-play):
- avgPly: 26 -> 90 (3.5x)
- Resignations: 100% -> 17%
- Checkmates: 0% -> 42%
- Threefold draws: 0% -> 41%
Vanilla regression check (80 games combined): 0 resigns either way,
strength unchanged (Casual still wins 98% vs random).
78 tests pass (was 75; +2 new check-resolution tests, +1 retry-cap test
updated to match new cap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hand-rolled scoring heuristic lost to a random-move baseline 7-7 in
self-play — far below the spec's >=80% acceptance bar. Swap in a real
chess engine (js-chess-engine, MIT, ~400KB, no native deps) for vanilla
mode at level 2 with randomness=30 to break threefold cycles.
- BrainInput.fen added; driver populates it ONLY in vanilla mode.
Blind mode omits the FEN so the engine path can't smuggle opponent
positions past the view filter.
- CasualBrain in vanilla: convert FEN -> EngineGame -> ai({level: 2});
validate the engine's move is in legalCandidates; fall back to
heuristic on miss.
- Blind mode unchanged (engine isn't useful when only own pieces are
visible — that's Phase 2 Recon's territory).
Self-play vs RandomBrain (100 games each direction, vanilla):
- Casual(W) vs Random(B): W=97%
- Random(W) vs Casual(B): B=96%
Casual-vs-Casual vanilla balanced, ~5-30ms/move. All 54 tests still pass.
Refresh .secrets.baseline (stale) to allow new pnpm-lock.yaml hashes.
Track aiOpponent in game store; show a pill badge in the topbar for AI
games, update turn label to "<Brain> is moving…" on the bot's turn,
and suppress the disconnected-opponent banner when the opponent is a bot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- maybeAbandon Promise no longer floats from setTimeout
- broadcastSinceLast loses dead extra parameter
- bot-slot token is randomized so a third party can't hijack the
bot's color by guessing a fixed placeholder
Replace broadcastNewAnnouncements/broadcastUpdate with watermark-based
broadcastSinceLast; add pokeBot helper; make all state-mutating handlers
async; hook pokeBot after every mutation so the CasualBrain fires on
each turn without oracle access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract endGame/finalizeIfEnded to game-end.ts so driver.ts can call
finalizeIfEnded after an applied move (fix: bot checkmate was not
setting game.status='finished'). Wrap entire dispatch() call in
try/catch for exception safety. Move lastSeenAnnouncementCount advance
to after successful dispatch so retry attempts see FSM rejection
announcements. Add checkmate-finalize test; lock retry-cap at 5 calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires Brain to Game: init/onStateChange/dispose lifecycle, in-flight
mutex, 5-attempt retry loop with attemptHistory, resign-on-cap. Also
adds Game.aiOpponent? field to state.ts for Task 5.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Svelte 5 $effect tracks every $state read inside its body. The
lifecycle effect that calls game.connect(gameId) implicitly read
state.ws (inside connect()) and then wrote to it, producing an
effect_update_depth_exceeded loop. Symptom in production: the
browser opened ~12 WS connections/sec, none completed the upgrade
handshake, and the lobby flow appeared stuck on 'waiting for
opponent' (the opponent's WS never stabilized long enough for the
server to send 'joined'). untrack() opts the call out of dep tracking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Svelte 5 runes ($state, $derived, $effect) only run through the
compiler in .svelte and .svelte.ts/js files. A plain .ts file leaves
$state(...) as a literal call at runtime, causing
"ReferenceError: $state is not defined" and a blank page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>