From 1550ae855bc765324d175cb2602b09e2b2c5449e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:44:41 +0000 Subject: [PATCH 1/4] chess: add a Stockfish bot at a waiting table --- src/apps/chess/__tests__/rules.test.ts | 16 ++++++++ src/apps/chess/components/ChessTable.tsx | 39 +++++++++++++++---- src/apps/chess/components/ScoreSheet.tsx | 5 ++- .../components/__tests__/ChessTable.test.tsx | 39 +++++++++++++++++++ src/apps/chess/rules.ts | 12 +++++- src/apps/chess/wire.ts | 4 ++ src/hooks/__tests__/useChessTable.test.tsx | 6 +++ src/hooks/useChessTable.ts | 5 ++- 8 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/apps/chess/__tests__/rules.test.ts b/src/apps/chess/__tests__/rules.test.ts index 3d4987f..45a0810 100644 --- a/src/apps/chess/__tests__/rules.test.ts +++ b/src/apps/chess/__tests__/rules.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + nameOf, glyph, describeResult, formatClock, @@ -126,3 +127,18 @@ describe('describeResult', () => { expect(describeResult({ ending: 'timeout' }, 'alice')).toBe('Draw: time ran out, and a bare king cannot win') }) }) + +// A bot's seat is named for its engine and strength; a player's id is +// already their name. +describe('nameOf', () => { + it('reads a bot’s seat as its engine and strength', () => { + expect(nameOf('stockfish@1500')).toBe('Stockfish 1500') + expect(nameOf('bouncy-coral-quokka-x9k2')).toBe('bouncy-coral-quokka-x9k2') + }) + + it('names a bot that won', () => { + expect(describeResult({ winner: 'stockfish@1500', winnerColor: 'white', ending: 'checkmate' }, 'alice')).toBe( + 'Stockfish 1500 won by checkmate' + ) + }) +}) diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index e4291a2..0ff42da 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom' import type { ChessTableActions } from '@/hooks/useChessTable' import felt from '@/apps/castle/components/CastleTable.module.css' import type { ChessColor, ChessView } from '../wire' -import { describeMove, describeResult, formatClock, glyph, lastMoveSquares, movesTo, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' +import { describeMove, describeResult, formatClock, glyph, lastMoveSquares, movesTo, nameOf, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' import styles from './ChessTable.module.css' import ScoreSheet from './ScoreSheet' @@ -29,6 +29,15 @@ const CLOCKS: Record = [ + { elo: 1320, name: 'Beginner' }, + { elo: 1600, name: 'Casual' }, + { elo: 1900, name: 'Club' }, + { elo: 2300, name: 'Strong' }, + { elo: 3190, name: 'Full strength' } +] + const PROMOTIONS: Array<{ letter: string; name: string }> = [ { letter: 'q', name: 'Queen' }, { letter: 'r', name: 'Rook' }, @@ -64,9 +73,9 @@ const ClockRow = ({ view, seatId, color, you }: { view: ChessView; seatId: strin return (
- {you ? `${seatId} (you)` : seatId} · {color} + {you ? `${nameOf(seatId)} (you)` : nameOf(seatId)} · {color} - + {formatClock(ms)}
@@ -107,6 +116,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const [promoting, setPromoting] = useState<{ view: ChessView; from: string; to: string; moves: string[] } | null>(null) const [confirmResign, setConfirmResign] = useState(null) const [clockChoice, setClockChoice] = useState('3+2') + const [botElo, setBotElo] = useState(BOT_STRENGTHS[1].elo) const me = view.players.find(player => player.playerId === playerId) const myColor: ChessColor = me?.color ?? 'white' @@ -215,15 +225,15 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { // A leave mid-game is that game's result; between games, the news. if (view.result?.ending === 'abandoned') return describeResult(view.result, playerId) const left = view.players.find(player => player.playerId !== playerId) - return `${left?.playerId ?? 'Your opponent'} left the table.` + return `${left === undefined ? 'Your opponent' : nameOf(left.playerId)} left the table.` } if (pendingPromotion !== null) return 'Choose a piece to promote to.' - if (!myTurn) return `${view.currentPlayerId ?? ''} to move.` + if (!myTurn) return `${nameOf(view.currentPlayerId ?? '')} to move.` // The move just made is the opponent's: say it, since the board only // shows it. const lastMove = view.moves[view.moves.length - 1] const lastMover = view.sideToMove === undefined ? undefined : view.players.find(player => player.color === other(view.sideToMove!)) - const played = lastMove !== undefined && lastMover !== undefined ? `${lastMover.playerId} played ${describeMove(lastMove)}. ` : '' + const played = lastMove !== undefined && lastMover !== undefined ? `${nameOf(lastMover.playerId)} played ${describeMove(lastMove)}. ` : '' return `${played}${view.inCheck ? 'Check. ' : ''}Your move.` })() @@ -255,9 +265,24 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
    {view.players.map(player => ( -
  • {player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}
  • +
  • {player.playerId === playerId ? `${player.playerId} (you)` : nameOf(player.playerId)}
  • ))}
+ {/* Alone at the table, a bot can take the other chair. */} + {view.players.length === 1 && ( +
+ + +
+ )}
diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index 3e73538..c2405b4 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -510,6 +510,19 @@ describe('ChessTable', () => { expect(t.addBot).toHaveBeenCalledWith(1900) }) + it('is asked for once per table state: a double tap sends one', () => { + const alone = waiting({ players: [{ playerId: 'alice' }] }) + const { t, rerender } = mountWith(alone) + const add = screen.getByRole('button', { name: 'Add a bot' }) + fireEvent.click(add) + fireEvent.click(add) + expect(t.addBot).toHaveBeenCalledTimes(1) + expect(add).toBeDisabled() + // A new view, as on a rejoin, offers it again. + rerender(waiting({ players: [{ playerId: 'alice' }] })) + expect(screen.getByRole('button', { name: 'Add a bot' })).toBeEnabled() + }) + it('is not offered once the second seat is taken, or off the hub', () => { const { unmount } = mountWith(waiting()) expect(screen.queryByRole('button', { name: 'Add a bot' })).toBeNull() diff --git a/src/apps/chess/rules.ts b/src/apps/chess/rules.ts index ea34b0a..4779474 100644 --- a/src/apps/chess/rules.ts +++ b/src/apps/chess/rules.ts @@ -118,7 +118,8 @@ const DRAWN_BY: Record = { } // A seat as people read it: a player's id is their name; a bot's -// ("stockfish@1500") its engine and strength. +// ("stockfish@1500") its engine and strength. The id's shape is the hub's +// bot-naming contract (games_hub chess_bots.h); a new engine extends it here. export function nameOf(playerId: string): string { const bot = /^stockfish@(\d+)$/.exec(playerId) return bot === null ? playerId : `Stockfish ${bot[1]}` From 423b020e41cd96bc4a4111b6d18cc8f95a996af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:37:53 +0000 Subject: [PATCH 4/4] chess: a refused bot lets Add a bot be asked again --- src/apps/chess/components/ChessTable.tsx | 14 ++------- .../components/__tests__/ChessTable.test.tsx | 15 +++------- src/hooks/__tests__/useChessTable.test.tsx | 12 ++++++++ src/hooks/useChessTable.ts | 29 ++++++++++++++----- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index f9c26fa..2f1d646 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -18,7 +18,7 @@ export interface ChessTableProps { playerId: string connected: boolean view: ChessView - table: ChessTableActions & { opening: boolean } + table: ChessTableActions & { opening: boolean; seating: boolean } } // The clocks a starter can pick, as minutes + increment seconds. @@ -117,8 +117,6 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const [confirmResign, setConfirmResign] = useState(null) const [clockChoice, setClockChoice] = useState('3+2') const [botElo, setBotElo] = useState(BOT_STRENGTHS[1].elo) - // Asked for against this view: the bot's seat or a new view releases it. - const [botAsked, setBotAsked] = useState(null) const me = view.players.find(player => player.playerId === playerId) const myColor: ChessColor = me?.color ?? 'white' @@ -280,15 +278,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { ))} - diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index c2405b4..2de3b9e 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -51,6 +51,7 @@ const waiting = (over: Partial = {}): ChessView => const table = (over: Partial = {}): ChessTableProps['table'] => ({ opening: false, + seating: false, startTable: vi.fn(), leaveTable: vi.fn(), playAgain: vi.fn(), @@ -510,17 +511,9 @@ describe('ChessTable', () => { expect(t.addBot).toHaveBeenCalledWith(1900) }) - it('is asked for once per table state: a double tap sends one', () => { - const alone = waiting({ players: [{ playerId: 'alice' }] }) - const { t, rerender } = mountWith(alone) - const add = screen.getByRole('button', { name: 'Add a bot' }) - fireEvent.click(add) - fireEvent.click(add) - expect(t.addBot).toHaveBeenCalledTimes(1) - expect(add).toBeDisabled() - // A new view, as on a rejoin, offers it again. - rerender(waiting({ players: [{ playerId: 'alice' }] })) - expect(screen.getByRole('button', { name: 'Add a bot' })).toBeEnabled() + it('is held while one is being seated, so a double tap asks once', () => { + mountWith(waiting({ players: [{ playerId: 'alice' }] }), { seating: true }) + expect(screen.getByRole('button', { name: 'Add a bot' })).toBeDisabled() }) it('is not offered once the second seat is taken, or off the hub', () => { diff --git a/src/hooks/__tests__/useChessTable.test.tsx b/src/hooks/__tests__/useChessTable.test.tsx index e579479..6a330b0 100644 --- a/src/hooks/__tests__/useChessTable.test.tsx +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -113,6 +113,18 @@ describe('useChessTable', () => { expect(result.current.opening).toBe(false) }) + it('holds a bot asked for until its seat arrives, or the hub refuses it', () => { + const { result, receive } = mount() + receive({ gameState: { view: view({ phase: 'waiting' }) } }) + act(() => result.current.addBot(1600)) + expect(result.current.seating).toBe(true) + receive({ gameState: { view: view({ phase: 'waiting' }) } }) + expect(result.current.seating).toBe(false) + act(() => result.current.addBot(1600)) + act(() => result.current.handleRejected()) + expect(result.current.seating).toBe(false) + }) + it('announces another seat’s table, never its own', () => { const { receive, showNotice } = mount() receive({ gameCreated: { gameId: 'GAME01', createdBy: 'alice' } }) diff --git a/src/hooks/useChessTable.ts b/src/hooks/useChessTable.ts index 10b95a6..cca3cab 100644 --- a/src/hooks/useChessTable.ts +++ b/src/hooks/useChessTable.ts @@ -26,6 +26,8 @@ export interface UseChessTable extends ChessTableActions { view: ChessView | null // Another game has been asked for and not yet arrived. opening: boolean + // A bot has been asked for and its seat not yet arrived. + seating: boolean handleUpdate: (update: ChessUpdate) => void handleRejected: () => void clear: () => void @@ -41,23 +43,28 @@ export interface UseChessTableProps { export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTableProps): UseChessTable => { const [view, setView] = useState(null) const [opening, setOpening] = useState(false) + const [seating, setSeating] = useState(false) - const clear = useCallback(() => { - setView(null) + // Held until the hub answers with a view, or refuses. + const settle = useCallback(() => { setOpening(false) + setSeating(false) }, []) - const handleRejected = useCallback(() => setOpening(false), []) + const clear = useCallback(() => { + setView(null) + settle() + }, [settle]) const handleUpdate = useCallback( (update: ChessUpdate) => { if (update.gameJoined) { setView(update.gameJoined.view) - setOpening(false) + settle() return } if (update.gameState) { setView(update.gameState.view) - setOpening(false) + settle() return } if (update.gameCreated) { @@ -70,7 +77,7 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa } // gameStarted, turnChanged and gameEnded: the view says it all. }, - [clear, onLeft, playerId, showNotice] + [clear, onLeft, playerId, settle, showNotice] ) const createTable = useCallback(() => move('createGame'), [move]) @@ -97,7 +104,13 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa }, [clear, move, onLeft, view]) const play = useCallback((uci: string) => move('play', { uci }), [move]) const resign = useCallback(() => move('resign'), [move]) - const addBot = useCallback((elo: number) => move('addBot', { elo }), [move]) + const addBot = useCallback( + (elo: number) => { + setSeating(true) + move('addBot', { elo }) + }, + [move] + ) - return { view, opening, handleUpdate, handleRejected, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign, addBot } + return { view, opening, seating, handleUpdate, handleRejected: settle, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign, addBot } }