From a7b26e5b43c16be0466f4fa69f1f0349c3cf0f16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:16:36 +0000 Subject: [PATCH 1/2] chess: next game at the table, score notepad --- README.md | 3 +- .../chess/components/ChessTable.module.css | 60 +++ src/apps/chess/components/ChessTable.tsx | 476 ++++++++++++------ src/apps/chess/components/ScoreSheet.tsx | 65 +++ .../components/__tests__/ChessTable.test.tsx | 44 +- src/apps/chess/wire.ts | 12 +- src/hooks/__tests__/useChessTable.test.tsx | 25 +- src/hooks/useChessTable.ts | 14 +- 8 files changed, 527 insertions(+), 172 deletions(-) create mode 100644 src/apps/chess/components/ScoreSheet.tsx diff --git a/README.md b/README.md index c87e587..1a404ec 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state room stream's game envelopes. Castle and rummy share the felt and the card faces (`castle/components/Cards.tsx`, `CastleTable.module.css`). `ChessTable` and `useChessTable` are chess's: two seats, the board from the viewer's side, the moves the hub lists for the -side to move (the client holds no rules), and the clocks counted down from each view. Share links are +side to move (the client holds no rules), the clocks counted down from each view, and a +score notepad as the table plays on: Next game starts another there, sides swapped. Share links are `/games/room/:roomId` and `/games/room/:roomId/table/:gameId`; the old `/golf` and `/castle` links redirect to them, and `/thoughts` to `/games`. Hiding the panel is how the bare world is asked for, so that choice is remembered across visits. diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css index 97a58f4..f49b225 100644 --- a/src/apps/chess/components/ChessTable.module.css +++ b/src/apps/chess/components/ChessTable.module.css @@ -228,3 +228,63 @@ font-size: 1.15rem; color: #f0c96a; } + +/* The header, and the score sheet beside it once there is one. */ +.top { + display: flex; + gap: 0.75rem; + align-items: flex-start; + justify-content: space-between; +} + +/* The score sheet: rummy's notepad, ruled like paper and written in a + hand, set at the side of the table rather than pinned over a felt. */ +.notepad { + flex: none; + width: max-content; + padding: 0.4rem 0.6rem 0.5rem 1.3rem; + border-radius: 2px; + background-color: #fdf8e2; + background-image: + linear-gradient(to right, transparent 0.95rem, rgba(214, 69, 65, 0.55) 0.95rem, rgba(214, 69, 65, 0.55) calc(0.95rem + 1px), transparent calc(0.95rem + 1px)), + repeating-linear-gradient(to bottom, transparent 0, transparent 1.2rem, rgba(80, 120, 190, 0.28) 1.2rem, rgba(80, 120, 190, 0.28) calc(1.2rem + 1px)); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35); + transform: rotate(-1.5deg); + color: #2a2a33; + font-family: 'Bradley Hand', 'Segoe Print', 'Comic Sans MS', cursive; + font-size: 0.8rem; + line-height: 1.2rem; +} + +.notepadSheet { + border-collapse: collapse; +} + +.notepadSheet th, +.notepadSheet td { + padding: 0 0.35rem; + text-align: right; + font-weight: normal; + white-space: nowrap; +} + +.notepadName { + display: inline-block; + max-width: 4.5rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + font-weight: 700; +} + +.notepadSheet tfoot td, +.notepadSheet tfoot th { + border-top: 2px solid #2a2a33; + font-weight: 700; +} + +.notepadSheet td.notepadDraw { + text-align: center; + font-style: italic; + opacity: 0.7; +} diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index 9880168..ff1abff 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -1,9 +1,21 @@ -import { useEffect, useId, useRef, useState } from 'react' -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 styles from './ChessTable.module.css' +import { useEffect, useId, useRef, useState } from "react"; +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 styles from "./ChessTable.module.css"; +import ScoreSheet from "./ScoreSheet"; // The board from the viewer's chair: their side at the bottom, the // opponent's clock above it and their own below. A tap on a piece offers @@ -12,176 +24,256 @@ import styles from './ChessTable.module.css' // as rummy's is. export interface ChessTableProps { - playerId: string - connected: boolean - view: ChessView - table: ChessTableActions & { opening: boolean } + playerId: string; + connected: boolean; + view: ChessView; + table: ChessTableActions & { opening: boolean }; } // The clocks a starter can pick, as minutes + increment seconds. -const CLOCKS: Record = { - '1+0': { initialSeconds: 60, incrementSeconds: 0 }, - '3+2': { initialSeconds: 180, incrementSeconds: 2 }, - '5+3': { initialSeconds: 300, incrementSeconds: 3 }, - '10+5': { initialSeconds: 600, incrementSeconds: 5 } -} +const CLOCKS: Record< + string, + { initialSeconds: number; incrementSeconds: number } +> = { + "1+0": { initialSeconds: 60, incrementSeconds: 0 }, + "3+2": { initialSeconds: 180, incrementSeconds: 2 }, + "5+3": { initialSeconds: 300, incrementSeconds: 3 }, + "10+5": { initialSeconds: 600, incrementSeconds: 5 }, +}; const PROMOTIONS: Array<{ letter: string; name: string }> = [ - { letter: 'q', name: 'Queen' }, - { letter: 'r', name: 'Rook' }, - { letter: 'b', name: 'Bishop' }, - { letter: 'n', name: 'Knight' } -] + { letter: "q", name: "Queen" }, + { letter: "r", name: "Rook" }, + { letter: "b", name: "Bishop" }, + { letter: "n", name: "Knight" }, +]; // How often a running clock repaints: fine enough for its tenths. -const TICK_MS = 100 +const TICK_MS = 100; -const colorOfPiece = (piece: string): ChessColor => (piece === piece.toUpperCase() ? 'white' : 'black') -const other = (color: ChessColor): ChessColor => (color === 'white' ? 'black' : 'white') +const colorOfPiece = (piece: string): ChessColor => + piece === piece.toUpperCase() ? "white" : "black"; +const other = (color: ChessColor): ChessColor => + color === "white" ? "black" : "white"; // One seat's clock. The hub stamps each view with the time left as it // built it, so the running side counts down from the moment its view // arrived. Its own component, so the tick repaints the clock and not the // board. -const ClockRow = ({ view, seatId, color, you }: { view: ChessView; seatId: string; color: ChessColor; you: boolean }) => { - const [arrived, setArrived] = useState(() => ({ view, at: Date.now() })) - const [now, setNow] = useState(() => Date.now()) - if (arrived.view !== view) setArrived({ view, at: Date.now() }) - const running = view.phase === 'playing' && view.sideToMove === color +const ClockRow = ({ + view, + seatId, + color, + you, +}: { + view: ChessView; + seatId: string; + color: ChessColor; + you: boolean; +}) => { + const [arrived, setArrived] = useState(() => ({ view, at: Date.now() })); + const [now, setNow] = useState(() => Date.now()); + if (arrived.view !== view) setArrived({ view, at: Date.now() }); + const running = view.phase === "playing" && view.sideToMove === color; useEffect(() => { - if (!running) return - const timer = window.setInterval(() => setNow(Date.now()), TICK_MS) - return () => window.clearInterval(timer) - }, [running]) - const stamped = view.clock === undefined ? 0 : color === 'white' ? view.clock.whiteMs : view.clock.blackMs - const ms = running ? stamped - Math.max(0, now - arrived.at) : stamped + if (!running) return; + const timer = window.setInterval(() => setNow(Date.now()), TICK_MS); + return () => window.clearInterval(timer); + }, [running]); + const stamped = + view.clock === undefined + ? 0 + : color === "white" + ? view.clock.whiteMs + : view.clock.blackMs; + const ms = running ? stamped - Math.max(0, now - arrived.at) : stamped; return ( -
+
{you ? `${seatId} (you)` : seatId} · {color} - + {formatClock(ms)}
- ) -} + ); +}; const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { - const { opening } = table - const headingRef = useRef(null) - const boardRef = useRef(null) - const resignRef = useRef(null) - const confirmRef = useRef(null) - const playAgainRef = useRef(null) + const { opening } = table; + const headingRef = useRef(null); + const boardRef = useRef(null); + const resignRef = useRef(null); + const confirmRef = useRef(null); + const playAgainRef = useRef(null); // Where focus goes once the control that had it is gone: a square, or // one side of the resignation. Applied after the render that swapped it. - const refocus = useRef<{ square: string } | 'resign' | 'confirm' | null>(null) + const refocus = useRef<{ square: string } | "resign" | "confirm" | null>( + null, + ); useEffect(() => { - headingRef.current?.focus() - }, []) + headingRef.current?.focus(); + }, []); useEffect(() => { - const target = refocus.current - if (target === null) return - refocus.current = null - if (target === 'resign') resignRef.current?.focus() - else if (target === 'confirm') confirmRef.current?.focus() - else boardRef.current?.querySelector(`[data-square="${target.square}"]`)?.focus() - }) + const target = refocus.current; + if (target === null) return; + refocus.current = null; + if (target === "resign") resignRef.current?.focus(); + else if (target === "confirm") confirmRef.current?.focus(); + else + boardRef.current + ?.querySelector(`[data-square="${target.square}"]`) + ?.focus(); + }); // The game's end is where the next thing to do is: another game. useEffect(() => { - if (view.phase === 'ended') playAgainRef.current?.focus() - }, [view.phase]) + if (view.phase === "ended" || view.phase === "closed") + playAgainRef.current?.focus(); + }, [view.phase]); - const targetNote = useId() + const targetNote = useId(); // The square picked up, the promotion waiting on a piece, and a // resignation half made — each keyed to the view it was made against, // so a new position drops it rather than acting on one that is gone. - const [picked, setPicked] = useState<{ view: ChessView; square: string } | null>(null) - const [promoting, setPromoting] = useState<{ view: ChessView; from: string; moves: string[] } | null>(null) - const [confirmResign, setConfirmResign] = useState(null) - const [clockChoice, setClockChoice] = useState('3+2') + const [picked, setPicked] = useState<{ + view: ChessView; + square: string; + } | null>(null); + const [promoting, setPromoting] = useState<{ + view: ChessView; + from: string; + moves: string[]; + } | null>(null); + const [confirmResign, setConfirmResign] = useState(null); + const [clockChoice, setClockChoice] = useState("3+2"); - const me = view.players.find(player => player.playerId === playerId) - const myColor: ChessColor = me?.color ?? 'white' - const opponent = view.players.find(player => player.playerId !== playerId) - const myTurn = view.phase === 'playing' && view.currentPlayerId === playerId - const from = picked?.view === view ? picked.square : null - const pendingPromotion = promoting?.view === view ? promoting : null - const targets = from === null ? [] : targetsFrom(view.legalMoves, from) - const board = readBoard(view.fen) - const last = lastMoveSquares(view.moves) + const me = view.players.find((player) => player.playerId === playerId); + const myColor: ChessColor = me?.color ?? "white"; + const opponent = view.players.find((player) => player.playerId !== playerId); + const myTurn = view.phase === "playing" && view.currentPlayerId === playerId; + const from = picked?.view === view ? picked.square : null; + const pendingPromotion = promoting?.view === view ? promoting : null; + const targets = from === null ? [] : targetsFrom(view.legalMoves, from); + const board = readBoard(view.fen); + const last = lastMoveSquares(view.moves); // The king of the side to move — or, once mated, of the side that was. - const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null + const checkedKing = view.inCheck + ? view.fen?.split(" ")[1] === "b" + ? "k" + : "K" + : null; const tap = (square: string) => { - if (!myTurn || !connected) return + if (!myTurn || !connected) return; // A tap on the board is a new gesture: a promotion still asking is // abandoned, never left up to send a second move for this turn. - setPromoting(null) + setPromoting(null); if (from !== null && targets.includes(square)) { - const moves = movesTo(view.legalMoves, from, square) - setPicked(null) - if (moves.length === 1) table.play(moves[0]) - else setPromoting({ view, from, moves }) - return + const moves = movesTo(view.legalMoves, from, square); + setPicked(null); + if (moves.length === 1) table.play(moves[0]); + else setPromoting({ view, from, moves }); + return; } // Another of the viewer's pieces with a move picks it up; anything // else lets go. - const movable = targetsFrom(view.legalMoves, square).length > 0 - setPicked(movable && square !== from ? { view, square } : null) - } + const movable = targetsFrom(view.legalMoves, square).length > 0; + setPicked(movable && square !== from ? { view, square } : null); + }; const cancelPromotion = () => { - if (pendingPromotion !== null) refocus.current = { square: pendingPromotion.from } - setPromoting(null) - } + if (pendingPromotion !== null) + refocus.current = { square: pendingPromotion.from }; + setPromoting(null); + }; const status = (() => { - if (view.phase === 'waiting') return view.players.length < 2 ? 'Waiting for a second seat.' : 'Pick a clock and start.' - if (view.phase === 'ended') return view.result === undefined ? '' : describeResult(view.result, playerId) - if (pendingPromotion !== null) return 'Choose a piece to promote to.' - if (!myTurn) return `${view.currentPlayerId ?? ''} to move.` + if (view.phase === "waiting") + return view.players.length < 2 + ? "Waiting for a second seat." + : "Pick a clock and start."; + if (view.phase === "ended") + return view.result === undefined + ? "" + : describeResult(view.result, playerId); + if (view.phase === "closed") { + // 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.`; + } + if (pendingPromotion !== null) return "Choose a piece to promote to."; + if (!myTurn) return `${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)}. ` : '' - return `${played}${view.inCheck ? 'Check. ' : ''}Your move.` - })() + 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)}. ` + : ""; + return `${played}${view.inCheck ? "Check. " : ""}Your move.`; + })(); - const squares = squaresFor(myColor) + const squares = squaresFor(myColor); return (
-
-

- Chess {view.gameId} - {view.variant === 'kpk' && · king and pawn} -

-

- {status} -

- {/* Leaving a game in play forfeits it: Resign is that, and it asks +
+
+

+ Chess {view.gameId} + {view.variant === "kpk" && ( + · king and pawn + )} +

+

+ {status} +

+ {/* Leaving a game in play forfeits it: Resign is that, and it asks first. Before the start, leaving costs nothing. */} - {view.phase === 'waiting' && ( - - )} + {view.phase === "waiting" && ( + + )} +
+
- {view.phase === 'waiting' ? ( + {view.phase === "waiting" ? (
    - {view.players.map(player => ( -
  • {player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}
  • + {view.players.map((player) => ( +
  • + {player.playerId === playerId + ? `${player.playerId} (you)` + : player.playerId} +
  • ))}
) : (
- {opponent?.color !== undefined && } + {opponent?.color !== undefined && ( + + )} a move -
+
{squares.map((square, index) => { - const piece = board.get(square) - const mine = piece !== undefined && colorOfPiece(piece) === myColor + const piece = board.get(square); + const mine = + piece !== undefined && colorOfPiece(piece) === myColor; // a1 dark: a square is dark where its file and rank index sum even. - const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0 - const target = targets.includes(square) + const light = + (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0; + const target = targets.includes(square); return ( - ) + ); })}
- {me?.color !== undefined && } + {me?.color !== undefined && ( + + )} {pendingPromotion !== null && (
{ - if (event.key === 'Escape') { - event.preventDefault() - cancelPromotion() + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelPromotion(); } }} > {PROMOTIONS.map(({ letter, name }, i) => { - const uci = pendingPromotion.moves.find(move => move.endsWith(letter)) - if (uci === undefined) return null + const uci = pendingPromotion.moves.find((move) => + move.endsWith(letter), + ); + if (uci === undefined) return null; return ( - ) + ); })} -
)} - {view.phase === 'playing' && me !== undefined && ( + {view.phase === "playing" && me !== undefined && (
{confirmResign === view ? ( <> -
)} - {view.phase === 'ended' && ( + {(view.phase === "ended" || view.phase === "closed") && (
+ {/* Between games the table is still both seats': the next game + is played here. Once a seat has left, another table. */}
- -
@@ -337,7 +501,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
)}
- ) -} + ); +}; -export default ChessTable +export default ChessTable; diff --git a/src/apps/chess/components/ScoreSheet.tsx b/src/apps/chess/components/ScoreSheet.tsx new file mode 100644 index 0000000..c263284 --- /dev/null +++ b/src/apps/chess/components/ScoreSheet.tsx @@ -0,0 +1,65 @@ +import type { ChessView } from '../wire' +import styles from './ChessTable.module.css' + +// The table's running score as a notepad, rummy's: a column a player, a +// line a game with a 1 in its winner's column, and the wins totalled under +// a rule. The page holds the last few games, so a long match's sheet never +// pushes the board off the screen; the totals count them all. + +const PAGE = 5 + +export interface ScoreSheetProps { + view: ChessView + playerId: string +} + +const ScoreSheet = ({ view, playerId }: ScoreSheetProps) => { + const lines = view.scoreSheet ?? [] + if (lines.length === 0) return null + const columns = view.players.map(player => player.playerId) + for (const line of lines) { + if (line.winner !== undefined && !columns.includes(line.winner)) columns.push(line.winner) + } + const first = Math.max(0, lines.length - PAGE) + const wins = (id: string) => lines.filter(line => line.winner === id).length + return ( +
+ + + + + {columns.map(id => ( + + ))} + + + + {lines.slice(first).map((line, i) => ( + + + {line.winner === undefined ? ( + + ) : ( + columns.map(id => ) + )} + + ))} + + + + + {columns.map(id => ( + + ))} + + +
# + {id === playerId ? 'you' : id} +
{first + i + 1} + draw + {id === line.winner ? 1 : }
Total{wins(id)}
+
+ ) +} + +export default ScoreSheet diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index 232c8de..1eb8f9e 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -338,23 +338,61 @@ describe('ChessTable', () => { }) describe('the ending', () => { - it('is said, and focus goes to another game', () => { + it('is said, and focus goes to the next game at this table', () => { const { t, rerender } = mountWith(view()) rerender(ended()) expect(status()).toHaveTextContent('You won by checkmate') // Said once: the panel's copy is for the eye, not read again. expect(screen.getByText('You won by checkmate', { selector: 'p:not([data-testid])' })).toHaveAttribute('aria-hidden', 'true') expect(screen.queryByRole('button', { name: 'Resign' })).toBeNull() - expect(screen.getByRole('button', { name: 'Play again' })).toHaveFocus() + expect(screen.getByRole('button', { name: 'Next game' })).toHaveFocus() + fireEvent.click(screen.getByRole('button', { name: 'Next game' })) + expect(t.playAgain).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Leave table' })) + expect(t.leaveTable).toHaveBeenCalledTimes(1) + }) + + it('once the opponent leaves between games, says so and offers another table', () => { + const { t } = mountWith(ended({ phase: 'closed' })) + expect(status()).toHaveTextContent('bob left the table.') + expect(screen.queryByRole('button', { name: 'Next game' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Play again' })) expect(t.playAgain).toHaveBeenCalledTimes(1) fireEvent.click(screen.getByRole('button', { name: 'Back to the room' })) expect(t.leaveTable).toHaveBeenCalledTimes(1) }) + it('a leave mid-game is the result itself', () => { + mountWith(ended({ phase: 'closed', result: { ending: 'abandoned', winner: 'alice', winnerColor: 'white' } })) + expect(status()).toHaveTextContent('You won: your opponent left') + }) + it('holds Play again while another table is opening', () => { - mountWith(ended(), { opening: true }) + mountWith(ended({ phase: 'closed' }), { opening: true }) expect(screen.getByRole('button', { name: 'Opening…' })).toBeDisabled() }) }) + + describe('the score sheet', () => { + it('is not there before a game has finished', () => { + mountWith(view()) + expect(screen.queryByRole('table', { name: 'Score sheet' })).toBeNull() + }) + + it('marks each game’s winner, a draw for neither, and totals the wins', () => { + mountWith( + view({ + scoreSheet: [ + { winner: 'alice', ending: 'checkmate' }, + { ending: 'stalemate' }, + { winner: 'bob', ending: 'resignation' }, + { winner: 'alice', ending: 'timeout' } + ] + }) + ) + const sheet = screen.getByRole('table', { name: 'Score sheet' }) + const rows = within(sheet).getAllByRole('row').map(row => row.textContent) + expect(rows).toEqual(['#youbob', '11—', '2draw', '3—1', '41—', 'Total21']) + }) + }) }) diff --git a/src/apps/chess/wire.ts b/src/apps/chess/wire.ts index 871c79c..8a97a2f 100644 --- a/src/apps/chess/wire.ts +++ b/src/apps/chess/wire.ts @@ -5,7 +5,9 @@ export type ChessColor = 'white' | 'black' -export type ChessPhase = 'waiting' | 'playing' | 'ended' +// ended: a game is over and the table waits on the next; closed: a seat +// left the table. +export type ChessPhase = 'waiting' | 'playing' | 'ended' | 'closed' export type ChessEnding = | 'checkmate' @@ -39,6 +41,12 @@ export interface ChessResult { ending: ChessEnding } +// One finished game: the winning player, absent for a draw. +export interface ChessScoreLine { + winner?: string + ending: ChessEnding +} + export interface ChessView { gameId: string phase: ChessPhase @@ -55,6 +63,8 @@ export interface ChessView { legalMoves: string[] clock?: ChessClock result?: ChessResult + // Every game the table finished, in order. Absent from a hub before tables played on. + scoreSheet?: ChessScoreLine[] } // The chess update union's JSON encoding: exactly one member present. diff --git a/src/hooks/__tests__/useChessTable.test.tsx b/src/hooks/__tests__/useChessTable.test.tsx index 477f231..59cd646 100644 --- a/src/hooks/__tests__/useChessTable.test.tsx +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -60,16 +60,20 @@ describe('useChessTable', () => { expect(move.mock.calls).toEqual([['play', { uci: 'e7e8q' }], ['resign']]) }) - it('leaving a game in play asks the hub; leaving an ended one only clears', () => { + // A table outlives its games: leaving one in play or between games asks + // the hub; only a closed table is gone already. + it('leaving a table asks the hub; leaving a closed one only clears', () => { const { result, receive, move, onLeft } = mount() receive({ gameJoined: { view: view() } }) act(() => result.current.leaveTable()) - expect(move.mock.calls).toEqual([['leaveGame']]) + receive({ gameState: { view: view({ phase: 'ended' }) } }) + act(() => result.current.leaveTable()) + expect(move.mock.calls).toEqual([['leaveGame'], ['leaveGame']]) expect(result.current.view).not.toBeNull() - receive({ gameState: { view: view({ phase: 'ended' }) } }) + receive({ gameState: { view: view({ phase: 'closed' }) } }) act(() => result.current.leaveTable()) - expect(move.mock.calls).toHaveLength(1) + expect(move.mock.calls).toHaveLength(2) expect(result.current.view).toBeNull() expect(onLeft).toHaveBeenCalledTimes(1) }) @@ -82,8 +86,17 @@ describe('useChessTable', () => { expect(onLeft).toHaveBeenCalledTimes(1) }) - it('play again opens a table, and a refusal lets it be asked again', () => { - const { result, move } = mount() + it('play again at an open table is its next game, on the same clock', () => { + const { result, receive, move } = mount() + receive({ gameState: { view: view({ phase: 'ended' }) } }) + act(() => result.current.playAgain()) + expect(move.mock.calls).toEqual([['startGame', { initialSeconds: 180, incrementSeconds: 2 }]]) + expect(result.current.opening).toBe(false) + }) + + it('play again from a closed table opens another, and a refusal lets it be asked again', () => { + const { result, receive, move } = mount() + receive({ gameState: { view: view({ phase: 'closed' }) } }) act(() => result.current.playAgain()) expect(result.current.opening).toBe(true) expect(move.mock.calls).toEqual([['createGame']]) diff --git a/src/hooks/useChessTable.ts b/src/hooks/useChessTable.ts index be35e0f..38b8132 100644 --- a/src/hooks/useChessTable.ts +++ b/src/hooks/useChessTable.ts @@ -11,8 +11,8 @@ export interface ChessTableActions { // Seconds; absent is the hub's default. startTable: (clock?: ChessMovePayloads['startGame']) => void leaveTable: () => void - // Another table, from the one that just ended: a create, since the - // finished game is already gone from the hub. + // Another game: the table's next, on its clock, while it is open; a new + // table once it has closed. playAgain: () => void play: (uci: string) => void resign: () => void @@ -72,17 +72,21 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa const createTable = useCallback(() => move('createGame'), [move]) const playAgain = useCallback(() => { + if (view?.phase === 'ended' && view.clock !== undefined) { + move('startGame', { initialSeconds: view.clock.initialMs / 1000, incrementSeconds: view.clock.incrementMs / 1000 }) + return + } setOpening(true) move('createGame') - }, [move]) + }, [move, view]) const joinTable = useCallback((gameId: string) => move('joinGame', { gameId }), [move]) const startTable = useCallback((clock: ChessMovePayloads['startGame'] = {}) => move('startGame', clock), [move]) const leaveTable = useCallback(() => { - if (view !== null && view.phase !== 'ended') { + if (view !== null && view.phase !== 'closed') { move('leaveGame') return } - // An ended table is already gone from the hub: only the view lingers. + // A closed table is already gone from the hub: only the view lingers. clear() onLeft?.() }, [clear, move, onLeft, view]) From 965898a77da38f2a344085fe5e0e4bec1ef7bfb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:03:54 +0000 Subject: [PATCH 2/2] chess: latch Next game; undo prettier churn --- src/apps/chess/components/ChessTable.tsx | 454 ++++++------------ .../components/__tests__/ChessTable.test.tsx | 9 + src/hooks/__tests__/useChessTable.test.tsx | 3 + src/hooks/useChessTable.ts | 7 +- 4 files changed, 168 insertions(+), 305 deletions(-) diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index ff1abff..a8899dc 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -1,21 +1,10 @@ -import { useEffect, useId, useRef, useState } from "react"; -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 styles from "./ChessTable.module.css"; -import ScoreSheet from "./ScoreSheet"; +import { useEffect, useId, useRef, useState } from 'react' +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 styles from './ChessTable.module.css' +import ScoreSheet from './ScoreSheet' // The board from the viewer's chair: their side at the bottom, the // opponent's clock above it and their own below. A tap on a piece offers @@ -24,208 +13,151 @@ import ScoreSheet from "./ScoreSheet"; // as rummy's is. export interface ChessTableProps { - playerId: string; - connected: boolean; - view: ChessView; - table: ChessTableActions & { opening: boolean }; + playerId: string + connected: boolean + view: ChessView + table: ChessTableActions & { opening: boolean } } // The clocks a starter can pick, as minutes + increment seconds. -const CLOCKS: Record< - string, - { initialSeconds: number; incrementSeconds: number } -> = { - "1+0": { initialSeconds: 60, incrementSeconds: 0 }, - "3+2": { initialSeconds: 180, incrementSeconds: 2 }, - "5+3": { initialSeconds: 300, incrementSeconds: 3 }, - "10+5": { initialSeconds: 600, incrementSeconds: 5 }, -}; +const CLOCKS: Record = { + '1+0': { initialSeconds: 60, incrementSeconds: 0 }, + '3+2': { initialSeconds: 180, incrementSeconds: 2 }, + '5+3': { initialSeconds: 300, incrementSeconds: 3 }, + '10+5': { initialSeconds: 600, incrementSeconds: 5 } +} const PROMOTIONS: Array<{ letter: string; name: string }> = [ - { letter: "q", name: "Queen" }, - { letter: "r", name: "Rook" }, - { letter: "b", name: "Bishop" }, - { letter: "n", name: "Knight" }, -]; + { letter: 'q', name: 'Queen' }, + { letter: 'r', name: 'Rook' }, + { letter: 'b', name: 'Bishop' }, + { letter: 'n', name: 'Knight' } +] // How often a running clock repaints: fine enough for its tenths. -const TICK_MS = 100; +const TICK_MS = 100 -const colorOfPiece = (piece: string): ChessColor => - piece === piece.toUpperCase() ? "white" : "black"; -const other = (color: ChessColor): ChessColor => - color === "white" ? "black" : "white"; +const colorOfPiece = (piece: string): ChessColor => (piece === piece.toUpperCase() ? 'white' : 'black') +const other = (color: ChessColor): ChessColor => (color === 'white' ? 'black' : 'white') // One seat's clock. The hub stamps each view with the time left as it // built it, so the running side counts down from the moment its view // arrived. Its own component, so the tick repaints the clock and not the // board. -const ClockRow = ({ - view, - seatId, - color, - you, -}: { - view: ChessView; - seatId: string; - color: ChessColor; - you: boolean; -}) => { - const [arrived, setArrived] = useState(() => ({ view, at: Date.now() })); - const [now, setNow] = useState(() => Date.now()); - if (arrived.view !== view) setArrived({ view, at: Date.now() }); - const running = view.phase === "playing" && view.sideToMove === color; +const ClockRow = ({ view, seatId, color, you }: { view: ChessView; seatId: string; color: ChessColor; you: boolean }) => { + const [arrived, setArrived] = useState(() => ({ view, at: Date.now() })) + const [now, setNow] = useState(() => Date.now()) + if (arrived.view !== view) setArrived({ view, at: Date.now() }) + const running = view.phase === 'playing' && view.sideToMove === color useEffect(() => { - if (!running) return; - const timer = window.setInterval(() => setNow(Date.now()), TICK_MS); - return () => window.clearInterval(timer); - }, [running]); - const stamped = - view.clock === undefined - ? 0 - : color === "white" - ? view.clock.whiteMs - : view.clock.blackMs; - const ms = running ? stamped - Math.max(0, now - arrived.at) : stamped; + if (!running) return + const timer = window.setInterval(() => setNow(Date.now()), TICK_MS) + return () => window.clearInterval(timer) + }, [running]) + const stamped = view.clock === undefined ? 0 : color === 'white' ? view.clock.whiteMs : view.clock.blackMs + const ms = running ? stamped - Math.max(0, now - arrived.at) : stamped return ( -
+
{you ? `${seatId} (you)` : seatId} · {color} - + {formatClock(ms)}
- ); -}; + ) +} const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { - const { opening } = table; - const headingRef = useRef(null); - const boardRef = useRef(null); - const resignRef = useRef(null); - const confirmRef = useRef(null); - const playAgainRef = useRef(null); + const { opening } = table + const headingRef = useRef(null) + const boardRef = useRef(null) + const resignRef = useRef(null) + const confirmRef = useRef(null) + const playAgainRef = useRef(null) // Where focus goes once the control that had it is gone: a square, or // one side of the resignation. Applied after the render that swapped it. - const refocus = useRef<{ square: string } | "resign" | "confirm" | null>( - null, - ); + const refocus = useRef<{ square: string } | 'resign' | 'confirm' | null>(null) useEffect(() => { - headingRef.current?.focus(); - }, []); + headingRef.current?.focus() + }, []) useEffect(() => { - const target = refocus.current; - if (target === null) return; - refocus.current = null; - if (target === "resign") resignRef.current?.focus(); - else if (target === "confirm") confirmRef.current?.focus(); - else - boardRef.current - ?.querySelector(`[data-square="${target.square}"]`) - ?.focus(); - }); + const target = refocus.current + if (target === null) return + refocus.current = null + if (target === 'resign') resignRef.current?.focus() + else if (target === 'confirm') confirmRef.current?.focus() + else boardRef.current?.querySelector(`[data-square="${target.square}"]`)?.focus() + }) // The game's end is where the next thing to do is: another game. useEffect(() => { - if (view.phase === "ended" || view.phase === "closed") - playAgainRef.current?.focus(); - }, [view.phase]); + if (view.phase === 'ended' || view.phase === 'closed') playAgainRef.current?.focus() + }, [view.phase]) - const targetNote = useId(); + const targetNote = useId() // The square picked up, the promotion waiting on a piece, and a // resignation half made — each keyed to the view it was made against, // so a new position drops it rather than acting on one that is gone. - const [picked, setPicked] = useState<{ - view: ChessView; - square: string; - } | null>(null); - const [promoting, setPromoting] = useState<{ - view: ChessView; - from: string; - moves: string[]; - } | null>(null); - const [confirmResign, setConfirmResign] = useState(null); - const [clockChoice, setClockChoice] = useState("3+2"); + const [picked, setPicked] = useState<{ view: ChessView; square: string } | null>(null) + const [promoting, setPromoting] = useState<{ view: ChessView; from: string; moves: string[] } | null>(null) + const [confirmResign, setConfirmResign] = useState(null) + const [clockChoice, setClockChoice] = useState('3+2') - const me = view.players.find((player) => player.playerId === playerId); - const myColor: ChessColor = me?.color ?? "white"; - const opponent = view.players.find((player) => player.playerId !== playerId); - const myTurn = view.phase === "playing" && view.currentPlayerId === playerId; - const from = picked?.view === view ? picked.square : null; - const pendingPromotion = promoting?.view === view ? promoting : null; - const targets = from === null ? [] : targetsFrom(view.legalMoves, from); - const board = readBoard(view.fen); - const last = lastMoveSquares(view.moves); + const me = view.players.find(player => player.playerId === playerId) + const myColor: ChessColor = me?.color ?? 'white' + const opponent = view.players.find(player => player.playerId !== playerId) + const myTurn = view.phase === 'playing' && view.currentPlayerId === playerId + const from = picked?.view === view ? picked.square : null + const pendingPromotion = promoting?.view === view ? promoting : null + const targets = from === null ? [] : targetsFrom(view.legalMoves, from) + const board = readBoard(view.fen) + const last = lastMoveSquares(view.moves) // The king of the side to move — or, once mated, of the side that was. - const checkedKing = view.inCheck - ? view.fen?.split(" ")[1] === "b" - ? "k" - : "K" - : null; + const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null const tap = (square: string) => { - if (!myTurn || !connected) return; + if (!myTurn || !connected) return // A tap on the board is a new gesture: a promotion still asking is // abandoned, never left up to send a second move for this turn. - setPromoting(null); + setPromoting(null) if (from !== null && targets.includes(square)) { - const moves = movesTo(view.legalMoves, from, square); - setPicked(null); - if (moves.length === 1) table.play(moves[0]); - else setPromoting({ view, from, moves }); - return; + const moves = movesTo(view.legalMoves, from, square) + setPicked(null) + if (moves.length === 1) table.play(moves[0]) + else setPromoting({ view, from, moves }) + return } // Another of the viewer's pieces with a move picks it up; anything // else lets go. - const movable = targetsFrom(view.legalMoves, square).length > 0; - setPicked(movable && square !== from ? { view, square } : null); - }; + const movable = targetsFrom(view.legalMoves, square).length > 0 + setPicked(movable && square !== from ? { view, square } : null) + } const cancelPromotion = () => { - if (pendingPromotion !== null) - refocus.current = { square: pendingPromotion.from }; - setPromoting(null); - }; + if (pendingPromotion !== null) refocus.current = { square: pendingPromotion.from } + setPromoting(null) + } const status = (() => { - if (view.phase === "waiting") - return view.players.length < 2 - ? "Waiting for a second seat." - : "Pick a clock and start."; - if (view.phase === "ended") - return view.result === undefined - ? "" - : describeResult(view.result, playerId); - if (view.phase === "closed") { + if (view.phase === 'waiting') return view.players.length < 2 ? 'Waiting for a second seat.' : 'Pick a clock and start.' + if (view.phase === 'ended') return view.result === undefined ? '' : describeResult(view.result, playerId) + if (view.phase === 'closed') { // 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.`; + 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.` } - if (pendingPromotion !== null) return "Choose a piece to promote to."; - if (!myTurn) return `${view.currentPlayerId ?? ""} to move.`; + if (pendingPromotion !== null) return 'Choose a piece to promote to.' + if (!myTurn) return `${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)}. ` - : ""; - return `${played}${view.inCheck ? "Check. " : ""}Your move.`; - })(); + 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)}. ` : '' + return `${played}${view.inCheck ? 'Check. ' : ''}Your move.` + })() - const squares = squaresFor(myColor); + const squares = squaresFor(myColor) return (
@@ -233,22 +165,15 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {

Chess {view.gameId} - {view.variant === "kpk" && ( - · king and pawn - )} + {view.variant === 'kpk' && · king and pawn}

{status}

{/* Leaving a game in play forfeits it: Resign is that, and it asks - first. Before the start, leaving costs nothing. */} - {view.phase === "waiting" && ( - )} @@ -256,24 +181,17 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
- {view.phase === "waiting" ? ( + {view.phase === 'waiting' ? (
    - {view.players.map((player) => ( -
  • - {player.playerId === playerId - ? `${player.playerId} (you)` - : player.playerId} -
  • + {view.players.map(player => ( +
  • {player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}
  • ))}
) : (
- {opponent?.color !== undefined && ( - - )} + {opponent?.color !== undefined && } a move -
+
{squares.map((square, index) => { - const piece = board.get(square); - const mine = - piece !== undefined && colorOfPiece(piece) === myColor; + const piece = board.get(square) + const mine = piece !== undefined && colorOfPiece(piece) === myColor // a1 dark: a square is dark where its file and rank index sum even. - const light = - (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0; - const target = targets.includes(square); + const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0 + const target = targets.includes(square) return ( - ); + ) })}
- {me?.color !== undefined && ( - - )} + {me?.color !== undefined && } {pendingPromotion !== null && (
{ - if (event.key === "Escape") { - event.preventDefault(); - cancelPromotion(); + onKeyDown={event => { + if (event.key === 'Escape') { + event.preventDefault() + cancelPromotion() } }} > {PROMOTIONS.map(({ letter, name }, i) => { - const uci = pendingPromotion.moves.find((move) => - move.endsWith(letter), - ); - if (uci === undefined) return null; + const uci = pendingPromotion.moves.find(move => move.endsWith(letter)) + if (uci === undefined) return null return ( - ); + ) })} -
)} - {view.phase === "playing" && me !== undefined && ( + {view.phase === 'playing' && me !== undefined && (
{confirmResign === view ? ( <> -
)} - {(view.phase === "ended" || view.phase === "closed") && ( + {(view.phase === 'ended' || view.phase === 'closed') && (
- -
@@ -501,7 +349,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
)}
- ); -}; + ) +} -export default ChessTable; +export default ChessTable diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index 1eb8f9e..b8e505a 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -394,5 +394,14 @@ describe('ChessTable', () => { const rows = within(sheet).getAllByRole('row').map(row => row.textContent) expect(rows).toEqual(['#youbob', '11—', '2draw', '3—1', '41—', 'Total21']) }) + + it('pages the last five games and totals them all', () => { + const won = (winner: string) => ({ winner, ending: 'checkmate' as const }) + mountWith(view({ scoreSheet: [won('alice'), won('alice'), won('bob'), won('alice'), won('bob'), won('bob'), won('alice')] })) + const rows = within(screen.getByRole('table', { name: 'Score sheet' })) + .getAllByRole('row') + .map(row => row.textContent) + expect(rows).toEqual(['#youbob', '3—1', '41—', '5—1', '6—1', '71—', 'Total43']) + }) }) }) diff --git a/src/hooks/__tests__/useChessTable.test.tsx b/src/hooks/__tests__/useChessTable.test.tsx index 59cd646..ee4d450 100644 --- a/src/hooks/__tests__/useChessTable.test.tsx +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -91,6 +91,9 @@ describe('useChessTable', () => { receive({ gameState: { view: view({ phase: 'ended' }) } }) act(() => result.current.playAgain()) expect(move.mock.calls).toEqual([['startGame', { initialSeconds: 180, incrementSeconds: 2 }]]) + // Held until the next game's view arrives, so a second tap cannot ask twice. + expect(result.current.opening).toBe(true) + receive({ gameState: { view: view() } }) expect(result.current.opening).toBe(false) }) diff --git a/src/hooks/useChessTable.ts b/src/hooks/useChessTable.ts index 38b8132..a4a906c 100644 --- a/src/hooks/useChessTable.ts +++ b/src/hooks/useChessTable.ts @@ -22,7 +22,7 @@ export interface UseChessTable extends ChessTableActions { createTable: () => void joinTable: (gameId: string) => void view: ChessView | null - // A table has been asked for and not yet arrived. + // Another game has been asked for and not yet arrived. opening: boolean handleUpdate: (update: ChessUpdate) => void handleRejected: () => void @@ -55,6 +55,7 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa } if (update.gameState) { setView(update.gameState.view) + setOpening(false) return } if (update.gameCreated) { @@ -71,12 +72,14 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa ) const createTable = useCallback(() => move('createGame'), [move]) + // Either way held until the hub answers: the next game's view, or the + // new table's. const playAgain = useCallback(() => { + setOpening(true) if (view?.phase === 'ended' && view.clock !== undefined) { move('startGame', { initialSeconds: view.clock.initialMs / 1000, incrementSeconds: view.clock.incrementMs / 1000 }) return } - setOpening(true) move('createGame') }, [move, view]) const joinTable = useCallback((gameId: string) => move('joinGame', { gameId }), [move])