From d4837b7d7c363ab53b314928c7c289c929cc345e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:26:06 +0000 Subject: [PATCH 1/3] chess: a table on the lobby, first variant king and pawn against king --- README.md | 6 +- src/apps/chess/__tests__/rules.test.ts | 112 ++++++++ .../chess/components/ChessTable.module.css | 244 ++++++++++++++++ src/apps/chess/components/ChessTable.tsx | 270 ++++++++++++++++++ .../components/__tests__/ChessTable.test.tsx | 170 +++++++++++ src/apps/chess/rules.ts | 114 ++++++++ src/apps/chess/wire.ts | 81 ++++++ .../lobby/__tests__/lobbyCommands.test.ts | 11 + src/apps/lobby/components/LobbyGame.tsx | 10 +- src/apps/lobby/components/LobbyPanel.tsx | 11 +- .../components/__tests__/LobbyGame.test.tsx | 18 +- .../components/__tests__/LobbyPanel.test.tsx | 21 ++ src/apps/lobby/lobbyCommands.ts | 5 +- src/apps/lobby/offers.ts | 12 +- src/hooks/__tests__/useChessTable.test.tsx | 97 +++++++ src/hooks/__tests__/useLobby.test.tsx | 27 ++ src/hooks/useChessTable.ts | 100 +++++++ src/hooks/useLobby.ts | 20 +- src/utils/__tests__/hubStream.test.ts | 2 + src/utils/hubStream.ts | 6 +- 20 files changed, 1320 insertions(+), 17 deletions(-) create mode 100644 src/apps/chess/__tests__/rules.test.ts create mode 100644 src/apps/chess/components/ChessTable.module.css create mode 100644 src/apps/chess/components/ChessTable.tsx create mode 100644 src/apps/chess/components/__tests__/ChessTable.test.tsx create mode 100644 src/apps/chess/rules.ts create mode 100644 src/apps/chess/wire.ts create mode 100644 src/hooks/__tests__/useChessTable.test.tsx create mode 100644 src/hooks/useChessTable.ts diff --git a/README.md b/README.md index d27ec4a3..c87e5870 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,16 @@ The nav's **Hmm** menu links to apps hosted off muchq.com (see the list). Those are external links, not routes — their code lives in their own repos, not here. -### The lobby, golf, castle, rummy, and thoughts +### The lobby, golf, castle, rummy, chess, and thoughts `/games` is the lobby (MoonBase#1490): the thoughts world with a panel for the room, its players and their tables, and the room's chat, all on one stream. A table of any game opens over the world (MoonBase#1502); `GolfTable`, `CastleTable` and `RummyTable` (MoonBase#245) are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state over the room stream's game envelopes. Castle and rummy share the felt and the card faces -(`castle/components/Cards.tsx`, `CastleTable.module.css`). Share links are +(`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 `/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/__tests__/rules.test.ts b/src/apps/chess/__tests__/rules.test.ts new file mode 100644 index 00000000..0dde2927 --- /dev/null +++ b/src/apps/chess/__tests__/rules.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + describeResult, + formatClock, + lastMoveSquares, + movesTo, + pieceName, + readBoard, + squaresFor, + targetsFrom +} from '../rules' + +describe('readBoard', () => { + it('reads the placement field into squares', () => { + const board = readBoard('7k/4P3/6K1/8/8/8/8/8 w - - 0 1') + expect(Object.fromEntries(board)).toEqual({ h8: 'k', e7: 'P', g6: 'K' }) + }) + + it('reads an empty rank run across the whole board', () => { + const board = readBoard('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1') + expect(board.size).toBe(32) + expect(board.get('e4')).toBe('P') + expect(board.get('e2')).toBeUndefined() + expect(board.get('a8')).toBe('r') + expect(board.get('h1')).toBe('R') + }) + + it('reads nothing from nothing', () => { + expect(readBoard(undefined).size).toBe(0) + expect(readBoard('').size).toBe(0) + }) +}) + +describe('squaresFor', () => { + it('puts White at the bottom: a8 first, h1 last', () => { + const squares = squaresFor('white') + expect(squares).toHaveLength(64) + expect(squares[0]).toBe('a8') + expect(squares[7]).toBe('h8') + expect(squares[63]).toBe('h1') + }) + + it('turns the board for Black: h1 first, a8 last', () => { + const squares = squaresFor('black') + expect(squares[0]).toBe('h1') + expect(squares[7]).toBe('a1') + expect(squares[63]).toBe('a8') + }) +}) + +describe('moves from the legal list', () => { + const legal = ['e7e8b', 'e7e8n', 'e7e8q', 'e7e8r', 'g6f5', 'g6f6', 'g6f7', 'g6g5', 'g6h5', 'g6h6'] + + it('the targets of a square are the squares its moves reach, once each', () => { + expect(targetsFrom(legal, 'e7')).toEqual(['e8']) + expect(targetsFrom(legal, 'g6')).toEqual(['f5', 'f6', 'f7', 'g5', 'h5', 'h6']) + expect(targetsFrom(legal, 'h8')).toEqual([]) + }) + + it('a square to a square is one move, or one per promotion piece', () => { + expect(movesTo(legal, 'g6', 'f7')).toEqual(['g6f7']) + expect(movesTo(legal, 'e7', 'e8')).toEqual(['e7e8b', 'e7e8n', 'e7e8q', 'e7e8r']) + expect(movesTo(legal, 'e7', 'e6')).toEqual([]) + }) +}) + +describe('lastMoveSquares', () => { + it('names the last move’s two squares, a promotion’s piece aside', () => { + expect(lastMoveSquares(['e2e4', 'e7e8q'])).toEqual(['e7', 'e8']) + expect(lastMoveSquares([])).toEqual([]) + }) +}) + +describe('formatClock', () => { + it('reads minutes and seconds, rounding up so zero means gone', () => { + expect(formatClock(180_000)).toBe('3:00') + expect(formatClock(61_001)).toBe('1:02') + expect(formatClock(10_000)).toBe('0:10') + }) + + it('shows tenths under ten seconds', () => { + expect(formatClock(9_950)).toBe('9.9') + expect(formatClock(420)).toBe('0.4') + expect(formatClock(0)).toBe('0.0') + expect(formatClock(-50)).toBe('0.0') + }) +}) + +describe('pieceName', () => { + it('names a piece by its color and kind', () => { + expect(pieceName('K')).toBe('white king') + expect(pieceName('p')).toBe('black pawn') + expect(pieceName('N')).toBe('white knight') + }) +}) + +describe('describeResult', () => { + it('speaks to the winner, the loser and a draw', () => { + const mate = { ending: 'checkmate' as const, winner: 'alice', winnerColor: 'white' as const } + expect(describeResult(mate, 'alice')).toBe('You won by checkmate') + expect(describeResult(mate, 'bob')).toBe('alice won by checkmate') + expect(describeResult({ ending: 'timeout', winner: 'bob', winnerColor: 'black' }, 'alice')).toBe('bob won on time') + expect(describeResult({ ending: 'resignation', winner: 'bob', winnerColor: 'black' }, 'bob')).toBe('You won by resignation') + expect(describeResult({ ending: 'abandoned', winner: 'bob', winnerColor: 'black' }, 'bob')).toBe('You won: your opponent left') + expect(describeResult({ ending: 'stalemate' }, 'alice')).toBe('Draw by stalemate') + expect(describeResult({ ending: 'insufficientMaterial' }, 'alice')).toBe('Draw: nobody can mate') + expect(describeResult({ ending: 'fiftyMoves' }, 'alice')).toBe('Draw by the fifty-move rule') + expect(describeResult({ ending: 'repetition' }, 'alice')).toBe('Draw by repetition') + // A flag against a bare king draws. + expect(describeResult({ ending: 'timeout' }, 'alice')).toBe('Draw: time ran out, and a bare king cannot win') + }) +}) diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css new file mode 100644 index 00000000..c3fbcb59 --- /dev/null +++ b/src/apps/chess/components/ChessTable.module.css @@ -0,0 +1,244 @@ +.table { + width: 100%; + max-width: 560px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + color: white; +} + +.title { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.muted { + color: rgba(255, 255, 255, 0.7); + font-weight: 400; + font-size: 1rem; +} + +.tableHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.hint { + flex: 1 1 10rem; + margin: 0; + text-align: center; + color: rgba(255, 255, 255, 0.85); + min-height: 1.5rem; +} + +.primary, +.secondary, +.danger, +.link { + padding: 0.6rem 1.25rem; + font-size: 1rem; + border-radius: 0.5rem; + cursor: pointer; + border: none; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.primary { + background: #f0c96a; + color: #3a2a00; + font-weight: 600; +} + +.secondary { + background: transparent; + color: white; + border: 1px solid rgba(255, 255, 255, 0.35); +} + +.danger { + background: #c0392b; + color: white; + font-weight: 600; +} + +.link { + background: none; + color: rgba(255, 255, 255, 0.7); + text-decoration: underline; + padding: 0.25rem 0; +} + +.primary:disabled, +.secondary:disabled, +.danger:disabled, +.link:disabled { + opacity: 0.45; + cursor: default; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.waiting { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.seats { + margin: 0; + padding-left: 1.25rem; +} + +.clockPick { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.play { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* The board fits the narrower of the column and the screen's height + less the two clocks and the actions, so the whole of it is on screen. */ +.board { + display: grid; + grid-template-columns: repeat(8, 1fr); + width: min(100%, calc(100vh - 14rem)); + aspect-ratio: 1; + align-self: center; + border: 2px solid rgba(0, 0, 0, 0.5); + border-radius: 4px; + overflow: hidden; +} + +.square { + position: relative; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + cursor: pointer; + font-size: clamp(1.25rem, 6vmin, 2.75rem); + line-height: 1; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.light { + background: #eeeed2; +} + +.dark { + background: #769656; +} + +.square[data-last='true'] { + box-shadow: inset 0 0 0 100vmax rgba(246, 246, 105, 0.45); +} + +.square[aria-pressed='true'] { + box-shadow: inset 0 0 0 100vmax rgba(20, 85, 30, 0.5); +} + +.square[data-check='true'] { + background: radial-gradient(circle, #ff3b30 0%, rgba(255, 59, 48, 0.4) 55%, transparent 75%); +} + +/* A move's target: a dot on an empty square, a ring round a capture. */ +.target::after { + content: ''; + position: absolute; + width: 30%; + height: 30%; + border-radius: 50%; + background: rgba(0, 0, 0, 0.25); + pointer-events: none; +} + +.square:focus-visible { + outline: 3px solid #f0c96a; + outline-offset: -3px; +} + +.whitePiece { + color: #fff; + text-shadow: 0 0 2px #000, 0 0 1px #000; +} + +.blackPiece { + color: #111; + text-shadow: 0 0 1px rgba(255, 255, 255, 0.6); +} + +.clockRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0.75rem; + border-radius: 0.5rem; + background: rgba(0, 0, 0, 0.3); +} + +.running { + background: rgba(240, 201, 106, 0.25); +} + +.clockName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clock { + font-variant-numeric: tabular-nums; + font-size: 1.4rem; + font-weight: 600; +} + +.low { + color: #ff6b5e; +} + +.promotion, +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + justify-content: center; +} + +.ending { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.result { + margin: 0; + font-size: 1.15rem; + color: #f0c96a; +} diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx new file mode 100644 index 00000000..021ccfab --- /dev/null +++ b/src/apps/chess/components/ChessTable.tsx @@ -0,0 +1,270 @@ +import { useEffect, useId, useRef, useState } from 'react' +import type { ChessTableActions } from '@/hooks/useChessTable' +import type { ChessColor, ChessResult, ChessView } from '../wire' +import { describeResult, formatClock, glyph, lastMoveSquares, movesTo, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' +import styles from './ChessTable.module.css' + +// 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 +// the moves the hub listed for it, and a tap on one of those plays it; +// the hub refuses anything else in band. + +export interface ChessTableProps { + playerId: string + connected: boolean + view: ChessView + table: ChessTableActions & { ended: ChessResult | null; 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 PROMOTIONS: Array<{ letter: string; name: string }> = [ + { 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 + +// The running side's time, counted down from the moment its view +// arrived. The hub stamps each view with the time left as it built it, +// so the count starts from there rather than from the turn's start. +function useRunningClock(view: ChessView): { white: number; black: number } { + 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 : undefined + useEffect(() => { + if (running === undefined) return + const timer = window.setInterval(() => setNow(Date.now()), TICK_MS) + return () => window.clearInterval(timer) + }, [running, view]) + const clock = view.clock + if (clock === undefined) return { white: 0, black: 0 } + const elapsed = Math.max(0, now - arrived.at) + return { + white: running === 'white' ? clock.whiteMs - elapsed : clock.whiteMs, + black: running === 'black' ? clock.blackMs - elapsed : clock.blackMs + } +} + +const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { + const { ended, opening } = table + const headingRef = useRef(null) + useEffect(() => { + headingRef.current?.focus() + }, []) + const targetNote = useId() + // The square picked up, and the promotion waiting on a piece — both + // keyed to the view they were made against, so a new position drops + // them rather than moving a piece that is no longer there. + const [picked, setPicked] = useState<{ view: ChessView; square: string } | null>(null) + const [promoting, setPromoting] = useState<{ view: ChessView; moves: string[] } | null>(null) + const [confirmResign, setConfirmResign] = useState(null) + const [clockChoice, setClockChoice] = useState('3+2') + const clocks = useRunningClock(view) + + 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.moves : 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 tap = (square: string) => { + if (!myTurn || !connected) return + 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, 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 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 '' + if (myTurn) return view.inCheck ? 'Check. Your move.' : 'Your move.' + return `${view.currentPlayerId ?? ''} to move.` + })() + + const renderClock = (seatId: string | undefined, color: ChessColor | undefined) => { + if (seatId === undefined || color === undefined || view.clock === undefined) return null + const ms = color === 'white' ? clocks.white : clocks.black + const running = view.phase === 'playing' && view.sideToMove === color + return ( +
+ + {seatId === playerId ? `${seatId} (you)` : seatId} · {color} + + + {formatClock(ms)} + +
+ ) + } + + return ( +
+
+

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

+

+ {status} +

+ {view.phase !== 'ended' && ( + + )} +
+ + {view.phase === 'waiting' ? ( +
+
    + {view.players.map(player => ( +
  • {player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}
  • + ))} +
+ + +
+ ) : ( +
+ {renderClock(opponent?.playerId, opponent?.color)} + + a move + +
+ {squaresFor(myColor).map(square => { + const piece = board.get(square) + const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 1 + const target = targets.includes(square) + return ( + + ) + })} +
+ {renderClock(me?.playerId, me?.color)} + + {pendingPromotion !== null && ( +
+ {PROMOTIONS.map(({ letter, name }) => { + const uci = pendingPromotion.find(move => move.endsWith(letter)) + if (uci === undefined) return null + return ( + + ) + })} + +
+ )} + + {view.phase === 'playing' && me !== undefined && ( +
+ {confirmResign === view ? ( + <> + + + + ) : ( + + )} +
+ )} + + {view.phase === 'ended' && ( +
+

+ {view.result !== undefined ? describeResult(view.result, playerId) : ''} +

+
+ {ended !== null && ( + + )} + +
+
+ )} +
+ )} +
+ ) +} + +export default ChessTable diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx new file mode 100644 index 00000000..7e846a3e --- /dev/null +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -0,0 +1,170 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ChessTable from '../ChessTable' +import type { ChessTableProps } from '../ChessTable' +import type { ChessView } from '../../wire' + +// The board from one chair, over a fake hook: what a tap on a square +// offers and sends, the clock running down, and the table's ending. + +// White Kg6 Pe7 against Kh8, alice White and on turn. +const view = (over: Partial = {}): ChessView => ({ + gameId: 'G1', + phase: 'playing', + variant: 'kpk', + players: [ + { playerId: 'alice', color: 'white' }, + { playerId: 'bob', color: 'black' } + ], + fen: '7k/4P3/6K1/8/8/8/8/8 w - - 0 1', + moves: [], + sideToMove: 'white', + currentPlayerId: 'alice', + inCheck: false, + legalMoves: ['e7e8b', 'e7e8n', 'e7e8q', 'e7e8r', 'g6f5', 'g6f6', 'g6f7', 'g6g5', 'g6h5', 'g6h6'], + clock: { whiteMs: 180_000, blackMs: 175_000, initialMs: 180_000, incrementMs: 2_000 }, + ...over +}) + +const table = (over: Partial = {}): ChessTableProps['table'] => ({ + ended: null, + opening: false, + startTable: vi.fn(), + leaveTable: vi.fn(), + playAgain: vi.fn(), + play: vi.fn(), + resign: vi.fn(), + ...over +}) + +const mountWith = (v: ChessView, over: Partial = {}, playerId = 'alice') => { + const t = table(over) + const rendered = render() + return { ...rendered, t } +} + +const square = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}\\b`) }) + +describe('ChessTable', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('sets the board from the FEN with the viewer’s side at the bottom', () => { + mountWith(view()) + const board = screen.getByRole('group', { name: 'board' }) + const cells = within(board).getAllByRole('button') + expect(cells).toHaveLength(64) + expect(cells[0]).toHaveAccessibleName('a8') + expect(cells[63]).toHaveAccessibleName('h1') + expect(square('h8')).toHaveAccessibleName('h8, black king') + expect(square('e7')).toHaveAccessibleName('e7, white pawn') + }) + + it('turns the board for Black', () => { + mountWith(view(), {}, 'bob') + const cells = within(screen.getByRole('group', { name: 'board' })).getAllByRole('button') + expect(cells[0]).toHaveAccessibleName('h1') + expect(cells[63]).toHaveAccessibleName('a8') + }) + + it('a tap on a piece offers its moves, and a tap on one plays it', () => { + const { t } = mountWith(view()) + fireEvent.click(square('g6')) + expect(square('g6')).toHaveAttribute('aria-pressed', 'true') + expect(square('f7')).toHaveAccessibleDescription('a move') + expect(square('e6')).not.toHaveAccessibleDescription('a move') + fireEvent.click(square('f7')) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) + + it('a tap on a square no move reaches lets the piece go and plays nothing', () => { + const { t } = mountWith(view()) + fireEvent.click(square('g6')) + fireEvent.click(square('a1')) + expect(t.play).not.toHaveBeenCalled() + expect(square('g6')).toHaveAttribute('aria-pressed', 'false') + }) + + it('a promotion asks which piece', () => { + const { t } = mountWith(view()) + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + expect(t.play).not.toHaveBeenCalled() + const picker = screen.getByRole('group', { name: 'promote to' }) + fireEvent.click(within(picker).getByRole('button', { name: 'Knight' })) + expect(t.play).toHaveBeenCalledWith('e7e8n') + }) + + // The legal moves are the side to move's, and every seat sees them: off + // turn, the other side's listed moves are not the viewer's to play. + it('off turn, the listed moves are not the viewer’s to play', () => { + const { t } = mountWith(view(), {}, 'bob') + fireEvent.click(square('g6')) + expect(square('g6')).toHaveAttribute('aria-pressed', 'false') + fireEvent.click(square('f7')) + expect(t.play).not.toHaveBeenCalled() + }) + + it('marks the last move and a king in check', () => { + mountWith(view({ moves: ['e7e8q'], fen: '4Q2k/8/6K1/8/8/8/8/8 b - - 0 1', sideToMove: 'black', currentPlayerId: 'bob', inCheck: true, legalMoves: [] })) + expect(square('e7').dataset.last).toBe('true') + expect(square('e8').dataset.last).toBe('true') + expect(square('h8').dataset.check).toBe('true') + expect(square('g6').dataset.check).toBeUndefined() + }) + + it('runs the side to move’s clock down from the view, and only that one', () => { + mountWith(view()) + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('3:00') + expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:55') + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('2:55') + expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:55') + }) + + it('a finished game’s clocks stand still', () => { + mountWith(view({ phase: 'ended', currentPlayerId: undefined, sideToMove: undefined, legalMoves: [], result: { ending: 'resignation', winner: 'bob', winnerColor: 'black' } })) + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('3:00') + }) + + it('resigning takes a second tap', () => { + const { t } = mountWith(view()) + fireEvent.click(screen.getByRole('button', { name: 'Resign' })) + expect(t.resign).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Confirm resign' })) + expect(t.resign).toHaveBeenCalledTimes(1) + }) + + it('a waiting table starts with the clock chosen', () => { + const { t } = mountWith(view({ phase: 'waiting', players: [{ playerId: 'alice' }, { playerId: 'bob' }], fen: undefined, legalMoves: [], clock: undefined, sideToMove: undefined, currentPlayerId: undefined })) + expect(screen.queryByRole('group', { name: 'board' })).toBeNull() + fireEvent.change(screen.getByRole('combobox', { name: 'Clock' }), { target: { value: '5+3' } }) + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + expect(t.startTable).toHaveBeenCalledWith({ initialSeconds: 300, incrementSeconds: 3 }) + }) + + it('a waiting table of one cannot start', () => { + mountWith(view({ phase: 'waiting', players: [{ playerId: 'alice' }], fen: undefined, legalMoves: [], clock: undefined })) + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled() + }) + + it('an ended table says who won and offers another', () => { + const ended = view({ phase: 'ended', currentPlayerId: undefined, sideToMove: undefined, legalMoves: [], result: { ending: 'checkmate', winner: 'alice', winnerColor: 'white' } }) + const { t } = mountWith(ended, { ended: ended.result }) + expect(screen.getByText('You won by checkmate')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Resign' })).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) + }) +}) diff --git a/src/apps/chess/rules.ts b/src/apps/chess/rules.ts new file mode 100644 index 00000000..e5abf7f5 --- /dev/null +++ b/src/apps/chess/rules.ts @@ -0,0 +1,114 @@ +import type { ChessColor, ChessEnding, ChessResult } from './wire' + +// Reading a chess view: the board out of its FEN, which squares a move +// from here reaches, the clock as a player reads it, and a result as a +// sentence. The hub owns the rules; nothing here decides a move is legal. + +const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + +// The placement field: square to piece, FEN's letters (upper case White). +export function readBoard(fen: string | undefined): Map { + const board = new Map() + if (!fen) return board + const rows = fen.split(' ')[0].split('/') + rows.forEach((row, index) => { + const rank = 8 - index + let file = 0 + for (const char of row) { + if (char >= '1' && char <= '8') { + file += Number(char) + continue + } + if (file < 8) board.set(`${FILES[file]}${rank}`, char) + file += 1 + } + }) + return board +} + +// The 64 squares in reading order from the viewer's chair: their own +// pieces at the bottom. +export function squaresFor(color: ChessColor): string[] { + const squares: string[] = [] + for (let rank = 8; rank >= 1; rank--) { + for (const file of FILES) squares.push(`${file}${rank}`) + } + return color === 'white' ? squares : squares.reverse() +} + +// Where the piece on `from` may go. +export function targetsFrom(legal: string[], from: string): string[] { + const targets = legal.filter(uci => uci.startsWith(from)).map(uci => uci.slice(2, 4)) + return [...new Set(targets)] +} + +// The move, or moves — one per piece a promotion can make — from one +// square to another. +export function movesTo(legal: string[], from: string, to: string): string[] { + return legal.filter(uci => uci.slice(0, 2) === from && uci.slice(2, 4) === to) +} + +export function lastMoveSquares(moves: string[]): string[] { + const last = moves[moves.length - 1] + return last === undefined ? [] : [last.slice(0, 2), last.slice(2, 4)] +} + +// m:ss, rounded up so a clock reads zero only once it is gone; tenths +// under ten seconds, where they decide games. +export function formatClock(ms: number): string { + const left = Math.max(0, ms) + if (left < 10_000) return (Math.floor(left / 100) / 10).toFixed(1) + const seconds = Math.ceil(left / 1000) + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` +} + +const PIECE_NAMES: Record = { + k: 'king', + q: 'queen', + r: 'rook', + b: 'bishop', + n: 'knight', + p: 'pawn' +} + +export function pieceName(piece: string): string { + const color = piece === piece.toUpperCase() ? 'white' : 'black' + return `${color} ${PIECE_NAMES[piece.toLowerCase()] ?? 'piece'}` +} + +// Filled glyphs for both sides, told apart by color, so neither side's +// pieces read as outlines. +const GLYPHS: Record = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' } + +export function glyph(piece: string): string { + return GLYPHS[piece.toLowerCase()] ?? '' +} + +const WON_BY: Record = { + checkmate: 'by checkmate', + resignation: 'by resignation', + timeout: 'on time', + abandoned: '', + stalemate: '', + insufficientMaterial: '', + fiftyMoves: '', + repetition: '' +} + +const DRAWN_BY: Record = { + stalemate: 'Draw by stalemate', + insufficientMaterial: 'Draw: nobody can mate', + fiftyMoves: 'Draw by the fifty-move rule', + repetition: 'Draw by repetition', + timeout: 'Draw: time ran out, and a bare king cannot win', + checkmate: 'Draw', + resignation: 'Draw', + abandoned: 'Draw' +} + +export function describeResult(result: ChessResult, playerId: string): string { + if (result.winner === undefined) return DRAWN_BY[result.ending] + const mine = result.winner === playerId + if (result.ending === 'abandoned') return mine ? 'You won: your opponent left' : `${result.winner} won: their opponent left` + return `${mine ? 'You' : result.winner} won ${WON_BY[result.ending]}` +} diff --git a/src/apps/chess/wire.ts b/src/apps/chess/wire.ts new file mode 100644 index 00000000..871c79cd --- /dev/null +++ b/src/apps/chess/wire.ts @@ -0,0 +1,81 @@ +// Chess's vocabulary on the room stream, mirroring MoonBase's +// model/chess.smithy. Chess hides nothing, so every seat gets the same +// view; the hub sends the legal moves, so the client needs no rules of +// its own beyond reading a position. + +export type ChessColor = 'white' | 'black' + +export type ChessPhase = 'waiting' | 'playing' | 'ended' + +export type ChessEnding = + | 'checkmate' + | 'stalemate' + | 'insufficientMaterial' + | 'fiftyMoves' + | 'repetition' + | 'resignation' + | 'timeout' + | 'abandoned' + +export interface ChessPlayer { + playerId: string + // Absent while waiting. + color?: ChessColor +} + +// Each side's time left as of the moment the hub built the view; the +// side to move's is running from there. +export interface ChessClock { + whiteMs: number + blackMs: number + initialMs: number + incrementMs: number +} + +// A draw has no winner. The winner may have left since. +export interface ChessResult { + winner?: string + winnerColor?: ChessColor + ending: ChessEnding +} + +export interface ChessView { + gameId: string + phase: ChessPhase + variant?: string + players: ChessPlayer[] + // Absent while waiting. + fen?: string + // Every move so far in UCI ("e2e4", "e7e8q"). + moves: string[] + sideToMove?: ChessColor + currentPlayerId?: string + inCheck: boolean + // The side to move's legal moves in UCI; empty unless playing. + legalMoves: string[] + clock?: ChessClock + result?: ChessResult +} + +// The chess update union's JSON encoding: exactly one member present. +export interface ChessUpdate { + gameJoined?: { view: ChessView } + gameState?: { view: ChessView } + gameCreated?: { gameId: string; createdBy?: string } + gameStarted?: Record + turnChanged?: { playerId: string } + gameEnded?: { result: ChessResult } + gameLeft?: { gameId: string } +} + +export interface ChessMovePayloads { + createGame: undefined + joinGame: { gameId: string } + // Seconds; absent is the hub's default, three minutes and two. + startGame: { initialSeconds?: number; incrementSeconds?: number } + leaveGame: undefined + play: { uci: string } + resign: undefined +} + +export type ChessMoveName = keyof ChessMovePayloads diff --git a/src/apps/lobby/__tests__/lobbyCommands.test.ts b/src/apps/lobby/__tests__/lobbyCommands.test.ts index 33a287ad..a19af506 100644 --- a/src/apps/lobby/__tests__/lobbyCommands.test.ts +++ b/src/apps/lobby/__tests__/lobbyCommands.test.ts @@ -18,6 +18,7 @@ const lobbyWith = (over: Partial): UseLobby => castle: { createTable: vi.fn(), joinTable: vi.fn() }, golf: { createTable: vi.fn(), joinTable: vi.fn() }, rummy: { createTable: vi.fn(), joinTable: vi.fn() }, + chess: { createTable: vi.fn(), joinTable: vi.fn() }, voice: voiceIn(), ...over, }) as unknown as UseLobby @@ -31,6 +32,8 @@ const room = (over: Partial = {}): HubRoom => ({ { gameId: 'C2', game: 'castle', status: 'playing', playerCount: 2 }, { gameId: 'C3', game: 'castle', status: 'waiting', playerCount: 4 }, { gameId: 'M1', game: 'rummy', status: 'waiting', playerCount: 1 }, + { gameId: 'K1', game: 'chess', status: 'waiting', playerCount: 1 }, + { gameId: 'K2', game: 'chess', status: 'waiting', playerCount: 2 }, ], ...over, }) @@ -86,12 +89,16 @@ describe('lobbyCommands', () => { 'Open a castle table', 'Open a golf table', 'Open a rummy table', + 'Open a chess table', 'Join castle table C1', 'Join golf table G1', 'Join rummy table M1', + 'Join chess table K1', 'Hide lobby panel', ]) expect(offered.find(c => c.label === 'Join golf table G1')!.detail).toBe('2/4 seated') + // Chess seats two: a table of two is full, and one of one says so. + expect(offered.find(c => c.label === 'Join chess table K1')!.detail).toBe('1/2 seated') }) it('a table in play or full is not offered, and one in the list is joined as its game', () => { @@ -107,6 +114,10 @@ describe('lobbyCommands', () => { expect(lobby.golf.createTable).toHaveBeenCalledTimes(1) run(lobby, 'Open a rummy table') expect(lobby.rummy.createTable).toHaveBeenCalledTimes(1) + run(lobby, 'Join chess table K1') + expect(lobby.chess.joinTable).toHaveBeenCalledWith('K1') + run(lobby, 'Open a chess table') + expect(lobby.chess.createTable).toHaveBeenCalledTimes(1) run(lobby, 'Leave the room') expect(lobby.leaveRoom).toHaveBeenCalledTimes(1) run(lobby, 'Open chat') diff --git a/src/apps/lobby/components/LobbyGame.tsx b/src/apps/lobby/components/LobbyGame.tsx index 91d393c6..9f8851fd 100644 --- a/src/apps/lobby/components/LobbyGame.tsx +++ b/src/apps/lobby/components/LobbyGame.tsx @@ -3,6 +3,7 @@ import ThoughtsGame from '@/apps/thoughts/components/ThoughtsGame' import CastleTable from '@/apps/castle/components/CastleTable' import GolfTable from '@/apps/golf/components/GolfTable' import RummyTable from '@/apps/rummy/components/RummyTable' +import ChessTable from '@/apps/chess/components/ChessTable' import RoomChat, { type RoomChatHandle } from './RoomChat' import CommandMenu, { type CommandMenuHandle } from './CommandMenu' import { lobbyCommands } from '../lobbyCommands' @@ -29,8 +30,8 @@ const panelWanted = (): boolean => safeLocalStorage.get(PANEL_KEY) !== 'hidden' const LobbyGame = (props: UseLobbyProps) => { const lobby = useLobby(props) - const { castle, golf, rummy, chat, connected, playerId, notice, room } = lobby - const atTable = castle.view !== null || golf.view !== null || rummy.view !== null + const { castle, golf, rummy, chess, chat, connected, playerId, notice, room } = lobby + const atTable = castle.view !== null || golf.view !== null || rummy.view !== null || chess.view !== null // A rummy dealer among these lets any seat deal (MoonBase#1609). const away = (room?.players ?? []).filter(player => !player.connected).map(player => player.playerId) // A table takes the screen, so the panel folds away when one comes up @@ -108,6 +109,11 @@ const LobbyGame = (props: UseLobbyProps) => { )} + {chess.view !== null && ( +
+ +
+ )} {golf.view !== null && (
`${player.gamesWon}/${player.g const GAME_BLURB = { castle: 'Shed every card first. 2s reset the deck, 10s clear it, four of a kind counts as a 10.', golf: 'Lowest hand wins. Peek at two, then draw and swap; knock to call the last round.', - rummy: 'Draw, lay down sets and runs, discard. First to empty their hand wins.' + rummy: 'Draw, lay down sets and runs, discard. First to empty their hand wins.', + chess: 'King and pawn against king, from a random position, on the clock. Promote and mate, or hold the draw.' } // The command menu has no button of its own: this is where it is told. @@ -152,7 +153,7 @@ const LobbyPanel = ({ lobby, roomCodeRef }: LobbyPanelProps) => { return (
  • - {game} {table.gameId} · {table.playerCount}/{TABLE_SEATS} · {table.status === 'choosing' ? 'between deals' : table.status} + {game} {table.gameId} · {table.playerCount}/{seatsOf(game)} · {table.status === 'choosing' ? 'between deals' : table.status}

    {GAME_BLURB.rummy}

    + +

    {GAME_BLURB.chess}

  • {COMMAND_HINT} diff --git a/src/apps/lobby/components/__tests__/LobbyGame.test.tsx b/src/apps/lobby/components/__tests__/LobbyGame.test.tsx index a856b491..6c12b38a 100644 --- a/src/apps/lobby/components/__tests__/LobbyGame.test.tsx +++ b/src/apps/lobby/components/__tests__/LobbyGame.test.tsx @@ -5,6 +5,7 @@ import type { UseLobby } from '@/hooks/useLobby' import { fakeVoiceMesh } from '@/test/fakeVoice' import type { CastleView } from '@/apps/castle/wire' import type { RummyView } from '@/apps/rummy/wire' +import type { ChessView } from '@/apps/chess/wire' import type { GameState } from '@/types/golf' import type { CommandRegistry } from '@/utils/commandRegistry' import { COMMAND_HOTKEY } from '@/utils/hotkeys' @@ -30,7 +31,8 @@ const state = { voice: fakeVoiceMesh(), castle: { view: null as CastleView | null, ended: null, selected: [] }, golf: { view: null as GameState | null, ended: null, peekCountdown: null }, - rummy: { view: null as RummyView | null, ended: null, selected: [] } + rummy: { view: null as RummyView | null, ended: null, selected: [] }, + chess: { view: null as ChessView | null, ended: null, opening: false } } as unknown as UseLobby vi.mock('@/hooks/useLobby', async importOriginal => ({ @@ -56,6 +58,7 @@ vi.mock('@/apps/thoughts/components/ThoughtsGame', () => ({ } })) vi.mock('@/apps/castle/components/CastleTable', () => ({ default: () =>
    table
    })) +vi.mock('@/apps/chess/components/ChessTable', () => ({ default: () =>
    chess table
    })) vi.mock('@/apps/rummy/components/RummyTable', () => ({ default: ({ away }: { away?: string[] }) =>
    rummy table{away !== undefined && away.length > 0 ? `, away: ${away.join(' ')}` : ''}
    })) @@ -81,6 +84,7 @@ describe('LobbyGame', () => { cleanup() state.castle.view = null state.golf.view = null + state.chess.view = null state.lost = null state.room = null localStorage.clear() @@ -154,6 +158,18 @@ describe('LobbyGame', () => { expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy() }) + it('a chess table is a table too: over the world, the panel folded', () => { + const { rerender } = render() + state.chess.view = { gameId: 'K1' } as ChessView + rerender() + expect(screen.getByText('chess table')).toBeTruthy() + expect(screen.queryByRole('complementary', { name: 'lobby' })).toBeNull() + state.chess.view = null + rerender() + expect(screen.queryByText('chess table')).toBeNull() + expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy() + }) + // Who the room shows gone is what lets a seat deal for an away dealer. it('hands the rummy table the seats the room shows away', () => { state.room = { diff --git a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx index 9adbf8f7..4aca42e6 100644 --- a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx +++ b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx @@ -45,6 +45,7 @@ const lobby = (over: Partial = {}): UseLobby => castle: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['castle'], golf: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['golf'], rummy: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['rummy'], + chess: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['chess'], ...over }) as UseLobby @@ -106,6 +107,25 @@ describe('LobbyPanel', () => { expect(hook.leaveRoom).toHaveBeenCalled() }) + // Chess seats two, where every other game seats four. + it('a chess table reads its own seats, is full at two, and opens from its own button', () => { + const hook = lobby({ + room: room({ + games: [ + { gameId: 'K1', game: 'chess', status: 'waiting', playerCount: 1 }, + { gameId: 'K2', game: 'chess', status: 'waiting', playerCount: 2 } + ] + }) + }) + render() + expect(screen.getByText('chess K1 · 1/2 · waiting')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Full chess K2' })).toHaveProperty('disabled', true) + fireEvent.click(screen.getByRole('button', { name: 'Join chess K1' })) + expect(hook.chess.joinTable).toHaveBeenCalledWith('K1') + fireEvent.click(screen.getByRole('button', { name: 'Open a chess table' })) + expect(hook.chess.createTable).toHaveBeenCalled() + }) + it('a member already at a table is offered no other', () => { const seated = room() seated.players[0] = { ...seated.players[0], table: { game: 'castle', gameId: 'G1' } } @@ -113,6 +133,7 @@ describe('LobbyPanel', () => { expect(screen.getByRole('button', { name: 'Open a castle table' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Open a golf table' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Open a rummy table' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Open a chess table' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Join castle G1' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Join golf G4' })).toHaveProperty('disabled', true) }) diff --git a/src/apps/lobby/lobbyCommands.ts b/src/apps/lobby/lobbyCommands.ts index a6e1dbd9..ff1812ed 100644 --- a/src/apps/lobby/lobbyCommands.ts +++ b/src/apps/lobby/lobbyCommands.ts @@ -1,7 +1,7 @@ import type { UseLobby } from '@/hooks/useLobby' import { lobbyRoomPath } from '@/hooks/useLobby' import type { Command } from '@/utils/commandRegistry' -import { atTable, tableFor, tableOffer, TABLE_SEATS } from './offers' +import { atTable, seatsOf, tableFor, tableOffer } from './offers' // What the page around the lobby does for these entries: the panel and // the chat are LobbyGame's, not the hook's. @@ -58,6 +58,7 @@ export function lobbyCommands(lobby: UseLobby, ui: LobbyUi): Command[] { commands.push({ id: 'open-castle', label: 'Open a castle table', run: lobby.castle.createTable }) commands.push({ id: 'open-golf', label: 'Open a golf table', run: lobby.golf.createTable }) commands.push({ id: 'open-rummy', label: 'Open a rummy table', run: lobby.rummy.createTable }) + commands.push({ id: 'open-chess', label: 'Open a chess table', run: lobby.chess.createTable }) for (const table of room.games) { if (!tableOffer(table).open) continue const game = table.game ?? 'golf' @@ -65,7 +66,7 @@ export function lobbyCommands(lobby: UseLobby, ui: LobbyUi): Command[] { commands.push({ id: `join-${game}-${table.gameId}`, label: `Join ${game} table ${table.gameId}`, - detail: `${table.playerCount}/${TABLE_SEATS} seated`, + detail: `${table.playerCount}/${seatsOf(table.game)} seated`, run: () => join(table.gameId), }) } diff --git a/src/apps/lobby/offers.ts b/src/apps/lobby/offers.ts index e6b53d11..b8760e69 100644 --- a/src/apps/lobby/offers.ts +++ b/src/apps/lobby/offers.ts @@ -4,13 +4,16 @@ import type { HubGameName, HubGameSummary, HubRoom } from '@/utils/hubStream' // What the lobby offers and when, read the same way by the panel's // buttons and the command menu's entries. -// Every game seats four. -export const TABLE_SEATS = 4 +// Chess seats two; every other game, four. A summary from before the +// game was named is golf's. +export function seatsOf(game: HubGameName | undefined): number { + return game === 'chess' ? 2 : 4 +} // How a table reads: open to join, or why not. export function tableOffer(table: HubGameSummary): { label: string; open: boolean } { if (table.status !== 'waiting') return { label: 'In play', open: false } - if (table.playerCount >= TABLE_SEATS) return { label: 'Full', open: false } + if (table.playerCount >= seatsOf(table.game)) return { label: 'Full', open: false } return { label: 'Join', open: true } } @@ -22,8 +25,9 @@ export function atTable(room: HubRoom, playerId: string): boolean { // The table hook that speaks a game's vocabulary: a table is joined in its // own game's envelope, or the hub refuses it. A summary from before the // game was named is golf's. -export function tableFor(lobby: Pick, game: HubGameName | undefined) { +export function tableFor(lobby: Pick, game: HubGameName | undefined) { if (game === 'castle') return lobby.castle if (game === 'rummy') return lobby.rummy + if (game === 'chess') return lobby.chess return lobby.golf } diff --git a/src/hooks/__tests__/useChessTable.test.tsx b/src/hooks/__tests__/useChessTable.test.tsx new file mode 100644 index 00000000..508ea69e --- /dev/null +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -0,0 +1,97 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useChessTable } from '../useChessTable' +import type { ChessView } from '@/apps/chess/wire' + +const view = (over: Partial = {}): ChessView => ({ + gameId: 'GAME01', + phase: 'playing', + variant: 'kpk', + players: [ + { playerId: 'alice', color: 'white' }, + { playerId: 'bob', color: 'black' } + ], + fen: '7k/4P3/6K1/8/8/8/8/8 w - - 0 1', + moves: [], + sideToMove: 'white', + currentPlayerId: 'alice', + inCheck: false, + legalMoves: ['e7e8q', 'g6f7'], + clock: { whiteMs: 180_000, blackMs: 180_000, initialMs: 180_000, incrementMs: 2_000 }, + ...over +}) + +describe('useChessTable', () => { + const mount = () => { + const move = vi.fn() + const onLeft = vi.fn() + const showNotice = vi.fn() + const hook = renderHook(() => useChessTable({ playerId: 'alice', move, showNotice, onLeft })) + const receive = (update: Parameters[0]) => + act(() => hook.result.current.handleUpdate(update)) + return { ...hook, move, onLeft, showNotice, receive } + } + + it('holds the latest view, and the result once the game ends', () => { + const { result, receive } = mount() + receive({ gameJoined: { view: view({ phase: 'waiting' }) } }) + expect(result.current.view?.phase).toBe('waiting') + receive({ gameState: { view: view() } }) + expect(result.current.view?.phase).toBe('playing') + expect(result.current.ended).toBeNull() + receive({ gameEnded: { result: { ending: 'resignation', winner: 'alice', winnerColor: 'white' } } }) + expect(result.current.ended?.ending).toBe('resignation') + }) + + it('a start names the clock in seconds', () => { + const { result, move } = mount() + act(() => result.current.startTable({ initialSeconds: 60, incrementSeconds: 1 })) + expect(move.mock.calls).toEqual([['startGame', { initialSeconds: 60, incrementSeconds: 1 }]]) + }) + + it('a play and a resignation go out as the hub spells them', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.play('e7e8q')) + act(() => result.current.resign()) + expect(move.mock.calls).toEqual([['play', { uci: 'e7e8q' }], ['resign']]) + }) + + it('leaving a game in play asks the hub; leaving an ended one only clears', () => { + const { result, receive, move, onLeft } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.leaveTable()) + expect(move.mock.calls).toEqual([['leaveGame']]) + expect(result.current.view).not.toBeNull() + + receive({ gameState: { view: view({ phase: 'ended' }) } }) + act(() => result.current.leaveTable()) + expect(move.mock.calls).toHaveLength(1) + expect(result.current.view).toBeNull() + expect(onLeft).toHaveBeenCalledTimes(1) + }) + + it('gameLeft clears the table and tells the owner', () => { + const { result, receive, onLeft } = mount() + receive({ gameJoined: { view: view() } }) + receive({ gameLeft: { gameId: 'GAME01' } }) + expect(result.current.view).toBeNull() + expect(onLeft).toHaveBeenCalledTimes(1) + }) + + it('play again opens a table, and a refusal lets it be asked again', () => { + const { result, move } = mount() + act(() => result.current.playAgain()) + expect(result.current.opening).toBe(true) + expect(move.mock.calls).toEqual([['createGame']]) + act(() => result.current.handleRejected()) + expect(result.current.opening).toBe(false) + }) + + it('announces another seat’s table, never its own', () => { + const { receive, showNotice } = mount() + receive({ gameCreated: { gameId: 'GAME01', createdBy: 'alice' } }) + receive({ gameCreated: { gameId: 'GAME02', createdBy: 'bob' } }) + expect(showNotice.mock.calls).toEqual([['bob opened chess table GAME02']]) + }) +}) diff --git a/src/hooks/__tests__/useLobby.test.tsx b/src/hooks/__tests__/useLobby.test.tsx index 8e011679..96dab66d 100644 --- a/src/hooks/__tests__/useLobby.test.tsx +++ b/src/hooks/__tests__/useLobby.test.tsx @@ -236,6 +236,33 @@ describe('useLobby', () => { expect(ws.lastSent()).toEqual({ event: 'rummy', payload: { move: { joinGame: { gameId: 'M1' } } } }) }) + it('a chess table swaps in on its own envelope, and its moves ride it', async () => { + const { result, ws, pathname } = await open() + act(() => ws.receive('roomState', roomState('R1'))) + act(() => result.current.chess.createTable()) + expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { createGame: {} } } }) + const chessView = { gameId: 'K1', phase: 'waiting', players: [], moves: [], inCheck: false, legalMoves: [] } + act(() => ws.receive('chess', { update: { gameJoined: { view: chessView } } })) + expect(result.current.chess.view?.gameId).toBe('K1') + expect(result.current.rummy.view).toBeNull() + expect(pathname()).toBe('/games/room/R1/table/K1') + act(() => result.current.chess.startTable({ initialSeconds: 60, incrementSeconds: 0 })) + expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { startGame: { initialSeconds: 60, incrementSeconds: 0 } } } }) + act(() => result.current.chess.play('e2e4')) + expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { play: { uci: 'e2e4' } } } }) + act(() => result.current.chess.playAgain()) + act(() => ws.receive('commandRejected', { reason: 'leave your current game first' })) + expect(result.current.chess.opening).toBe(false) + act(() => ws.receive('roomLeft', { roomId: 'R1' })) + expect(result.current.chess.view).toBeNull() + }) + + it('a share link to a chess table sits at it in chess’s envelope', async () => { + const { ws } = await open({ permalinkRoomId: 'R1', permalinkGameId: 'K1' }, '/games/room/R1/table/K1') + act(() => ws.receive('roomState', roomState('R1', [{ gameId: 'K1', game: 'chess', status: 'waiting', playerCount: 1 }]))) + expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { joinGame: { gameId: 'K1' } } } }) + }) + it('joining a listed golf table sends its join, and the table answers', async () => { const { result, ws } = await open() act(() => ws.receive('roomState', roomState('R1', [{ gameId: 'G7', game: 'golf', status: 'waiting', playerCount: 1 }]))) diff --git a/src/hooks/useChessTable.ts b/src/hooks/useChessTable.ts new file mode 100644 index 00000000..ce62ab62 --- /dev/null +++ b/src/hooks/useChessTable.ts @@ -0,0 +1,100 @@ +import { useCallback, useState } from 'react' +import type { ChessMoveName, ChessMovePayloads, ChessResult, ChessUpdate, ChessView } from '@/apps/chess/wire' + +// A chess table as the wire sends it, over the lobby's stream (useLobby). +// The owner feeds handleUpdate every chess update and clears the table on +// a resume. The board's own selection is the component's: it lives and +// dies with one view. + +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. + playAgain: () => void + play: (uci: string) => void + resign: () => void +} + +export interface UseChessTable extends ChessTableActions { + createTable: () => void + joinTable: (gameId: string) => void + view: ChessView | null + ended: ChessResult | null + // A table has been asked for and not yet arrived. + opening: boolean + handleUpdate: (update: ChessUpdate) => void + handleRejected: () => void + clear: () => void +} + +export interface UseChessTableProps { + playerId: string + move: (name: N, payload?: ChessMovePayloads[N]) => void + showNotice: (message: string) => void + onLeft?: () => void +} + +export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTableProps): UseChessTable => { + const [view, setView] = useState(null) + const [ended, setEnded] = useState(null) + const [opening, setOpening] = useState(false) + + const clear = useCallback(() => { + setView(null) + setEnded(null) + setOpening(false) + }, []) + const handleRejected = useCallback(() => setOpening(false), []) + + const handleUpdate = useCallback( + (update: ChessUpdate) => { + if (update.gameJoined) { + setView(update.gameJoined.view) + setEnded(null) + setOpening(false) + return + } + if (update.gameState) { + setView(update.gameState.view) + return + } + if (update.gameCreated) { + if (update.gameCreated.createdBy !== playerId) showNotice(`${update.gameCreated.createdBy} opened chess table ${update.gameCreated.gameId}`) + return + } + if (update.gameEnded) { + setEnded(update.gameEnded.result) + return + } + if (update.gameLeft) { + clear() + onLeft?.() + } + // gameStarted and turnChanged: the view that follows says it all. + }, + [clear, onLeft, playerId, showNotice] + ) + + const createTable = useCallback(() => move('createGame'), [move]) + const playAgain = useCallback(() => { + setOpening(true) + move('createGame') + }, [move]) + 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') { + move('leaveGame') + return + } + // An ended table is already gone from the hub: only the view lingers. + clear() + onLeft?.() + }, [clear, move, onLeft, view]) + const play = useCallback((uci: string) => move('play', { uci }), [move]) + const resign = useCallback(() => move('resign'), [move]) + + return { view, ended, opening, handleUpdate, handleRejected, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign } +} diff --git a/src/hooks/useLobby.ts b/src/hooks/useLobby.ts index b83c4a9d..2ab9b5f8 100644 --- a/src/hooks/useLobby.ts +++ b/src/hooks/useLobby.ts @@ -11,10 +11,13 @@ import { VoiceMesh } from '@/utils/voiceMesh' import type { VoiceDevice } from '@/utils/voiceMesh' import { browserVoiceDevice } from '@/utils/voiceDevice' import type { CastleMoveName, CastleUpdate } from '@/apps/castle/wire' +import type { ChessMoveName, ChessUpdate } from '@/apps/chess/wire' import type { GolfMoveName, GolfUpdate } from '@/apps/golf/wire' import type { RummyMoveName, RummyUpdate } from '@/apps/rummy/wire' import { useCastleTable } from './useCastleTable' import type { UseCastleTable } from './useCastleTable' +import { useChessTable } from './useChessTable' +import type { UseChessTable } from './useChessTable' import { useGolfTable } from './useGolfTable' import type { UseGolfTable } from './useGolfTable' import { useRummyTable } from './useRummyTable' @@ -81,6 +84,7 @@ export interface UseLobby { castle: UseCastleTable golf: UseGolfTable rummy: UseRummyTable + chess: UseChessTable } // A share link's room the session is on its way to: left the resumed @@ -181,6 +185,9 @@ export const useLobby = ({ const rummyMove = useCallback((name: RummyMoveName, payload: unknown = {}) => { streamRef.current?.move('rummy', name, payload) }, []) + const chessMove = useCallback((name: ChessMoveName, payload: unknown = {}) => { + streamRef.current?.move('chess', name, payload) + }, []) const onTableLeft = useCallback(() => { if (roomIdRef.current !== null) navigate(lobbyRoomPath(roomIdRef.current), { replace: true }) }, [navigate]) @@ -193,10 +200,14 @@ export const useLobby = ({ const rummy = useRummyTable({ playerId, move: rummyMove, showNotice, onLeft: onTableLeft }) const rummyRef = useRef(rummy) rummyRef.current = rummy + const chess = useChessTable({ playerId, move: chessMove, showNotice, onLeft: onTableLeft }) + const chessRef = useRef(chess) + chessRef.current = chess const clearTables = useCallback(() => { castleRef.current.clear() golfRef.current.clear() rummyRef.current.clear() + chessRef.current.clear() }, []) // The share link's table, once its room is in hand: a table still @@ -222,6 +233,7 @@ export const useLobby = ({ } if (table.game === 'castle') castleRef.current.joinTable(gameId) else if (table.game === 'rummy') rummyRef.current.joinTable(gameId) + else if (table.game === 'chess') chessRef.current.joinTable(gameId) else golfRef.current.joinTable(gameId) }, [showNotice] @@ -320,6 +332,7 @@ export const useLobby = ({ // Whatever was refused, nothing a table asked for arrived. castleRef.current.handleRejected() rummyRef.current.handleRejected() + chessRef.current.handleRejected() voice.rejected(reason) chatSeqRef.current += 1 setChat(prev => ({ ...prev, rejection: { seq: chatSeqRef.current, reason } })) @@ -353,6 +366,10 @@ export const useLobby = ({ const rummyUpdate = update as RummyUpdate rummyRef.current.handleUpdate(rummyUpdate) joined = rummyUpdate.gameJoined?.view.gameId + } else if (game === 'chess') { + const chessUpdate = update as ChessUpdate + chessRef.current.handleUpdate(chessUpdate) + joined = chessUpdate.gameJoined?.view.gameId } else if (game === 'golf') { const golfUpdate = update as GolfUpdate golfRef.current.handleUpdate(golfUpdate) @@ -443,6 +460,7 @@ export const useLobby = ({ voice, castle, golf, - rummy + rummy, + chess } } diff --git a/src/utils/__tests__/hubStream.test.ts b/src/utils/__tests__/hubStream.test.ts index 26e1374c..b043f3e3 100644 --- a/src/utils/__tests__/hubStream.test.ts +++ b/src/utils/__tests__/hubStream.test.ts @@ -242,6 +242,8 @@ describe('HubStream', () => { expect(callbacks.onGame).toHaveBeenCalledWith('golf', { gameStarted: {} }) ws.receive('rummy', { update: { gameLeft: { playerId: 'bob' } } }) expect(callbacks.onGame).toHaveBeenCalledWith('rummy', { gameLeft: { playerId: 'bob' } }) + ws.receive('chess', { update: { gameEnded: { result: { ending: 'stalemate' } } } }) + expect(callbacks.onGame).toHaveBeenCalledWith('chess', { gameEnded: { result: { ending: 'stalemate' } } }) }) it('a terminal refusal is lost, not rejected, named by its shape when it says nothing', async () => { diff --git a/src/utils/hubStream.ts b/src/utils/hubStream.ts index a3e08bab..ce720e17 100644 --- a/src/utils/hubStream.ts +++ b/src/utils/hubStream.ts @@ -1,7 +1,7 @@ // The games hub's one stream, game-agnostic: the session mint, the // smithy event-stream socket with its reconnect loop, the room and chat // commands and events, the lobby envelope (the world), the voice -// envelope (the room's voice), and one envelope per game (golf, castle, rummy) +// envelope (the room's voice), and one envelope per game (golf, castle, rummy, chess) // whose contents are the game client's business. // // Wire shape (smithy-cpp ADR-0018 JSON-text mode): @@ -38,7 +38,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // --- wire shapes (mirrors model/games.smithy; each game envelope stays opaque) --- -export type HubGameName = 'golf' | 'castle' | 'rummy' +export type HubGameName = 'golf' | 'castle' | 'rummy' | 'chess' // The table a member is at, pending or in play (MoonBase#1490); absent // while idle, which is how the lobby tells who is free. @@ -109,6 +109,7 @@ type HubFrame = | { event: 'golf'; payload: { update: Record } } | { event: 'castle'; payload: { update: Record } } | { event: 'rummy'; payload: { update: Record } } + | { event: 'chess'; payload: { update: Record } } | { event: 'lobby'; payload: { update: LobbyUpdate } } | { event: 'voice'; payload: { update: VoiceUpdate } } | { event?: undefined; exception: string; payload: { message?: string } } @@ -285,6 +286,7 @@ export class HubStream { case 'golf': case 'castle': case 'rummy': + case 'chess': this.callbacks.onGame?.(frame.event, frame.payload.update) return case 'lobby': From 3d9ad00fb85eb591f5f5f1d12df1cc95ba926ede Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:37:56 +0000 Subject: [PATCH 2/3] chess: panel fixes: focus, announcements, leave, board sizing --- src/apps/chess/__tests__/rules.test.ts | 8 +- .../chess/components/ChessTable.module.css | 141 +++---- src/apps/chess/components/ChessTable.tsx | 218 +++++++---- .../components/__tests__/ChessTable.test.tsx | 366 +++++++++++++----- src/apps/chess/rules.ts | 15 +- .../components/__tests__/LobbyGame.test.tsx | 2 +- src/apps/stats/__tests__/rollup.test.ts | 8 + src/apps/stats/rollup.ts | 1 + src/hooks/__tests__/useChessTable.test.tsx | 9 +- src/hooks/__tests__/useLobby.test.tsx | 2 + src/hooks/useChessTable.ts | 19 +- 11 files changed, 515 insertions(+), 274 deletions(-) diff --git a/src/apps/chess/__tests__/rules.test.ts b/src/apps/chess/__tests__/rules.test.ts index 0dde2927..4ebaaaab 100644 --- a/src/apps/chess/__tests__/rules.test.ts +++ b/src/apps/chess/__tests__/rules.test.ts @@ -78,9 +78,11 @@ describe('formatClock', () => { expect(formatClock(10_000)).toBe('0:10') }) - it('shows tenths under ten seconds', () => { - expect(formatClock(9_950)).toBe('9.9') - expect(formatClock(420)).toBe('0.4') + it('shows tenths under ten seconds, still rounding up', () => { + expect(formatClock(9_950)).toBe('0:10') + expect(formatClock(9_900)).toBe('9.9') + expect(formatClock(420)).toBe('0.5') + expect(formatClock(50)).toBe('0.1') expect(formatClock(0)).toBe('0.0') expect(formatClock(-50)).toBe('0.0') }) diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css index c3fbcb59..bce54b06 100644 --- a/src/apps/chess/components/ChessTable.module.css +++ b/src/apps/chess/components/ChessTable.module.css @@ -1,3 +1,7 @@ +/* The board, the clocks and chess's own controls. The table's chrome — + title, header row, status line, buttons — is castle's + (CastleTable.module.css), as rummy's is. */ + .table { width: 100%; max-width: 560px; @@ -8,92 +12,29 @@ color: white; } -.title { - margin: 0; - font-size: 1.5rem; - font-weight: 600; -} - -.muted { +.variant { color: rgba(255, 255, 255, 0.7); font-weight: 400; font-size: 1rem; } -.tableHeader { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -.hint { - flex: 1 1 10rem; - margin: 0; - text-align: center; - color: rgba(255, 255, 255, 0.85); - min-height: 1.5rem; -} - -.primary, -.secondary, -.danger, -.link { - padding: 0.6rem 1.25rem; +.danger { + padding: 0.75rem 1.5rem; font-size: 1rem; border-radius: 0.5rem; - cursor: pointer; border: none; - -webkit-tap-highlight-color: transparent; - touch-action: manipulation; -} - -.primary { - background: #f0c96a; - color: #3a2a00; - font-weight: 600; -} - -.secondary { - background: transparent; - color: white; - border: 1px solid rgba(255, 255, 255, 0.35); -} - -.danger { + cursor: pointer; background: #c0392b; color: white; font-weight: 600; + touch-action: manipulation; } -.link { - background: none; - color: rgba(255, 255, 255, 0.7); - text-decoration: underline; - padding: 0.25rem 0; -} - -.primary:disabled, -.secondary:disabled, -.danger:disabled, -.link:disabled { +.danger:disabled { opacity: 0.45; cursor: default; } -.srOnly { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - border: 0; -} - .waiting { display: flex; flex-direction: column; @@ -118,12 +59,16 @@ gap: 0.5rem; } -/* The board fits the narrower of the column and the screen's height - less the two clocks and the actions, so the whole of it is on screen. */ +/* The board fits the narrower of the column and the screen's height less + the header, both clocks and the actions (about 19rem), so the whole of + it is on screen; below 16rem the overlay scrolls instead of shrinking + the squares past a tap. The pieces are sized from the board, not the + screen, so they always fit their squares. */ .board { + container-type: inline-size; display: grid; grid-template-columns: repeat(8, 1fr); - width: min(100%, calc(100vh - 14rem)); + width: max(16rem, min(100%, calc(100dvh - 19rem))); aspect-ratio: 1; align-self: center; border: 2px solid rgba(0, 0, 0, 0.5); @@ -140,18 +85,18 @@ padding: 0; border: none; cursor: pointer; - font-size: clamp(1.25rem, 6vmin, 2.75rem); + font-size: 9cqi; line-height: 1; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } .light { - background: #eeeed2; + background-color: #eeeed2; } .dark { - background: #769656; + background-color: #769656; } .square[data-last='true'] { @@ -159,11 +104,12 @@ } .square[aria-pressed='true'] { - box-shadow: inset 0 0 0 100vmax rgba(20, 85, 30, 0.5); + box-shadow: inset 0 0 0 100vmax rgba(246, 246, 105, 0.8); } +/* An image over the square's own colour, never in place of it. */ .square[data-check='true'] { - background: radial-gradient(circle, #ff3b30 0%, rgba(255, 59, 48, 0.4) 55%, transparent 75%); + background-image: radial-gradient(circle, #ff3b30 0%, rgba(255, 59, 48, 0.45) 55%, transparent 75%); } /* A move's target: a dot on an empty square, a ring round a capture. */ @@ -173,10 +119,18 @@ width: 30%; height: 30%; border-radius: 50%; - background: rgba(0, 0, 0, 0.25); + background: rgba(0, 0, 0, 0.4); pointer-events: none; } +.target:has(.whitePiece, .blackPiece)::after { + width: 88%; + height: 88%; + background: none; + border: 4px solid rgba(0, 0, 0, 0.4); + box-sizing: border-box; +} + .square:focus-visible { outline: 3px solid #f0c96a; outline-offset: -3px; @@ -192,6 +146,37 @@ text-shadow: 0 0 1px rgba(255, 255, 255, 0.6); } +/* The files along the bottom edge and the ranks down the left, from the + viewer's side, in the colour of the other square. */ +.rank, +.file { + position: absolute; + font-size: 2.6cqi; + font-weight: 600; + line-height: 1; + pointer-events: none; +} + +.rank { + top: 0.15em; + left: 0.2em; +} + +.file { + bottom: 0.15em; + right: 0.25em; +} + +.light .rank, +.light .file { + color: #769656; +} + +.dark .rank, +.dark .file { + color: #eeeed2; +} + .clockRow { display: flex; justify-content: space-between; diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index 021ccfab..fd6d39b9 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -1,19 +1,21 @@ import { useEffect, useId, useRef, useState } from 'react' import type { ChessTableActions } from '@/hooks/useChessTable' -import type { ChessColor, ChessResult, ChessView } from '../wire' -import { describeResult, formatClock, glyph, lastMoveSquares, movesTo, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' +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' // 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 // the moves the hub listed for it, and a tap on one of those plays it; -// the hub refuses anything else in band. +// the hub refuses anything else in band. The table's chrome is castle's, +// as rummy's is. export interface ChessTableProps { playerId: string connected: boolean view: ChessView - table: ChessTableActions & { ended: ChessResult | null; opening: boolean } + table: ChessTableActions & { opening: boolean } } // The clocks a starter can pick, as minutes + increment seconds. @@ -34,50 +36,78 @@ const PROMOTIONS: Array<{ letter: string; name: string }> = [ // How often a running clock repaints: fine enough for its tenths. const TICK_MS = 100 -// The running side's time, counted down from the moment its view -// arrived. The hub stamps each view with the time left as it built it, -// so the count starts from there rather than from the turn's start. -function useRunningClock(view: ChessView): { white: number; black: number } { +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 : undefined + const running = view.phase === 'playing' && view.sideToMove === color useEffect(() => { - if (running === undefined) return + if (!running) return const timer = window.setInterval(() => setNow(Date.now()), TICK_MS) return () => window.clearInterval(timer) - }, [running, view]) - const clock = view.clock - if (clock === undefined) return { white: 0, black: 0 } - const elapsed = Math.max(0, now - arrived.at) - return { - white: running === 'white' ? clock.whiteMs - elapsed : clock.whiteMs, - black: running === 'black' ? clock.blackMs - elapsed : clock.blackMs - } + }, [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 { ended, opening } = table + 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) useEffect(() => { 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() + }) + // The game's end is where the next thing to do is: another game. + useEffect(() => { + if (view.phase === 'ended') playAgainRef.current?.focus() + }, [view.phase]) + const targetNote = useId() - // The square picked up, and the promotion waiting on a piece — both - // keyed to the view they were made against, so a new position drops - // them rather than moving a piece that is no longer there. + // 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; moves: 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 clocks = useRunningClock(view) 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.moves : 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) @@ -86,11 +116,14 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const tap = (square: string) => { 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) 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, moves }) + else setPromoting({ view, from, moves }) return } // Another of the viewer's pieces with a move picks it up; anything @@ -99,41 +132,40 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { setPicked(movable && square !== from ? { view, square } : null) } + const cancelPromotion = () => { + 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 '' - if (myTurn) return view.inCheck ? 'Check. Your move.' : 'Your move.' - return `${view.currentPlayerId ?? ''} to move.` + 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.` + // 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 renderClock = (seatId: string | undefined, color: ChessColor | undefined) => { - if (seatId === undefined || color === undefined || view.clock === undefined) return null - const ms = color === 'white' ? clocks.white : clocks.black - const running = view.phase === 'playing' && view.sideToMove === color - return ( -
    - - {seatId === playerId ? `${seatId} (you)` : seatId} · {color} - - - {formatClock(ms)} - -
    - ) - } + const squares = squaresFor(myColor) return (
    -
    -

    +
    +

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

    -

    +

    {status}

    - {view.phase !== 'ended' && ( - )} @@ -158,7 +190,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
    ) : (
    - {renderClock(opponent?.playerId, opponent?.color)} - + {opponent?.color !== undefined && } + a move -
    - {squaresFor(myColor).map(square => { +
    + {squares.map((square, index) => { const piece = board.get(square) + const mine = piece !== undefined && colorOfPiece(piece) === myColor const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 1 const target = targets.includes(square) return (
    - {renderClock(me?.playerId, me?.color)} + {me?.color !== undefined && } {pendingPromotion !== null && ( -
    - {PROMOTIONS.map(({ letter, name }) => { - const uci = pendingPromotion.find(move => move.endsWith(letter)) +
    { + 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 return ( ) })} -
    @@ -229,15 +285,31 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
    {confirmResign === view ? ( <> - - ) : ( - )} @@ -246,16 +318,12 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { {view.phase === 'ended' && (
    -

    - {view.result !== undefined ? describeResult(view.result, playerId) : ''} -

    +

    {status}

    - {ended !== null && ( - - )} - +
    diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index 7e846a3e..c27cc2ca 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -5,7 +5,8 @@ import type { ChessTableProps } from '../ChessTable' import type { ChessView } from '../../wire' // The board from one chair, over a fake hook: what a tap on a square -// offers and sends, the clock running down, and the table's ending. +// offers and sends, what the table says and where focus goes, the clock +// running down, and the table's ending. // White Kg6 Pe7 against Kh8, alice White and on turn. const view = (over: Partial = {}): ChessView => ({ @@ -26,8 +27,29 @@ const view = (over: Partial = {}): ChessView => ({ ...over }) +const ended = (over: Partial = {}): ChessView => + view({ + phase: 'ended', + currentPlayerId: undefined, + sideToMove: undefined, + legalMoves: [], + result: { ending: 'checkmate', winner: 'alice', winnerColor: 'white' }, + ...over + }) + +const waiting = (over: Partial = {}): ChessView => + view({ + phase: 'waiting', + players: [{ playerId: 'alice' }, { playerId: 'bob' }], + fen: undefined, + legalMoves: [], + clock: undefined, + sideToMove: undefined, + currentPlayerId: undefined, + ...over + }) + const table = (over: Partial = {}): ChessTableProps['table'] => ({ - ended: null, opening: false, startTable: vi.fn(), leaveTable: vi.fn(), @@ -37,13 +59,15 @@ const table = (over: Partial = {}): ChessTableProps['t ...over }) -const mountWith = (v: ChessView, over: Partial = {}, playerId = 'alice') => { +const mountWith = (v: ChessView, over: Partial = {}, playerId = 'alice', connected = true) => { const t = table(over) - const rendered = render() - return { ...rendered, t } + const rendered = render() + const rerender = (next: ChessView) => rendered.rerender() + return { ...rendered, rerender, t } } const square = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}\\b`) }) +const status = () => screen.getByTestId('chess-status') describe('ChessTable', () => { beforeEach(() => { @@ -53,118 +77,264 @@ describe('ChessTable', () => { vi.useRealTimers() }) - it('sets the board from the FEN with the viewer’s side at the bottom', () => { - mountWith(view()) - const board = screen.getByRole('group', { name: 'board' }) - const cells = within(board).getAllByRole('button') - expect(cells).toHaveLength(64) - expect(cells[0]).toHaveAccessibleName('a8') - expect(cells[63]).toHaveAccessibleName('h1') - expect(square('h8')).toHaveAccessibleName('h8, black king') - expect(square('e7')).toHaveAccessibleName('e7, white pawn') - }) + describe('the board', () => { + it('is set from the FEN with the viewer’s side at the bottom', () => { + mountWith(view()) + const cells = within(screen.getByRole('group', { name: 'board' })).getAllByRole('button') + expect(cells).toHaveLength(64) + expect(cells[0]).toHaveAccessibleName('a8') + expect(cells[63]).toHaveAccessibleName('h1') + expect(square('h8')).toHaveAccessibleName('h8, black king') + expect(square('e7')).toHaveAccessibleName('e7, white pawn') + }) - it('turns the board for Black', () => { - mountWith(view(), {}, 'bob') - const cells = within(screen.getByRole('group', { name: 'board' })).getAllByRole('button') - expect(cells[0]).toHaveAccessibleName('h1') - expect(cells[63]).toHaveAccessibleName('a8') - }) + it('turns for Black', () => { + mountWith(view(), {}, 'bob') + const cells = within(screen.getByRole('group', { name: 'board' })).getAllByRole('button') + expect(cells[0]).toHaveAccessibleName('h1') + expect(cells[63]).toHaveAccessibleName('a8') + }) - it('a tap on a piece offers its moves, and a tap on one plays it', () => { - const { t } = mountWith(view()) - fireEvent.click(square('g6')) - expect(square('g6')).toHaveAttribute('aria-pressed', 'true') - expect(square('f7')).toHaveAccessibleDescription('a move') - expect(square('e6')).not.toHaveAccessibleDescription('a move') - fireEvent.click(square('f7')) - expect(t.play).toHaveBeenCalledWith('g6f7') - }) + it('marks its edges with the files and ranks, from the viewer’s side', () => { + const { unmount } = mountWith(view()) + const board = screen.getByRole('group', { name: 'board' }) + expect(within(board).getAllByTestId('coordinate').map(c => c.textContent)).toEqual(['8', '7', '6', '5', '4', '3', '2', '1', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) + unmount() + mountWith(view(), {}, 'bob') + const flipped = screen.getByRole('group', { name: 'board' }) + expect(within(flipped).getAllByTestId('coordinate').map(c => c.textContent)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', 'h', 'g', 'f', 'e', 'd', 'c', 'b', 'a']) + }) - it('a tap on a square no move reaches lets the piece go and plays nothing', () => { - const { t } = mountWith(view()) - fireEvent.click(square('g6')) - fireEvent.click(square('a1')) - expect(t.play).not.toHaveBeenCalled() - expect(square('g6')).toHaveAttribute('aria-pressed', 'false') - }) + it('reads as a toggle only on the viewer’s own pieces', () => { + mountWith(view()) + expect(square('g6')).toHaveAttribute('aria-pressed', 'false') + expect(square('h8')).not.toHaveAttribute('aria-pressed') + expect(square('a1')).not.toHaveAttribute('aria-pressed') + }) - it('a promotion asks which piece', () => { - const { t } = mountWith(view()) - fireEvent.click(square('e7')) - fireEvent.click(square('e8')) - expect(t.play).not.toHaveBeenCalled() - const picker = screen.getByRole('group', { name: 'promote to' }) - fireEvent.click(within(picker).getByRole('button', { name: 'Knight' })) - expect(t.play).toHaveBeenCalledWith('e7e8n') + it('marks the last move and a king in check', () => { + mountWith(view({ moves: ['e7e8q'], fen: '4Q2k/8/6K1/8/8/8/8/8 b - - 0 1', sideToMove: 'black', currentPlayerId: 'bob', inCheck: true, legalMoves: [] })) + expect(square('e7').dataset.last).toBe('true') + expect(square('e8').dataset.last).toBe('true') + expect(square('h8').dataset.check).toBe('true') + expect(square('g6').dataset.check).toBeUndefined() + }) }) - // The legal moves are the side to move's, and every seat sees them: off - // turn, the other side's listed moves are not the viewer's to play. - it('off turn, the listed moves are not the viewer’s to play', () => { - const { t } = mountWith(view(), {}, 'bob') - fireEvent.click(square('g6')) - expect(square('g6')).toHaveAttribute('aria-pressed', 'false') - fireEvent.click(square('f7')) - expect(t.play).not.toHaveBeenCalled() - }) + describe('moving', () => { + it('a tap on a piece offers its moves, and a tap on one plays it', () => { + const { t } = mountWith(view()) + fireEvent.click(square('g6')) + expect(square('g6')).toHaveAttribute('aria-pressed', 'true') + expect(square('f7')).toHaveAccessibleDescription('a move') + expect(square('e6')).not.toHaveAccessibleDescription('a move') + fireEvent.click(square('f7')) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) - it('marks the last move and a king in check', () => { - mountWith(view({ moves: ['e7e8q'], fen: '4Q2k/8/6K1/8/8/8/8/8 b - - 0 1', sideToMove: 'black', currentPlayerId: 'bob', inCheck: true, legalMoves: [] })) - expect(square('e7').dataset.last).toBe('true') - expect(square('e8').dataset.last).toBe('true') - expect(square('h8').dataset.check).toBe('true') - expect(square('g6').dataset.check).toBeUndefined() - }) + it('a tap on a square no move reaches lets the piece go and plays nothing', () => { + const { t } = mountWith(view()) + fireEvent.click(square('g6')) + fireEvent.click(square('a1')) + expect(t.play).not.toHaveBeenCalled() + expect(square('g6')).toHaveAttribute('aria-pressed', 'false') + }) + + // The legal moves are the side to move's, and every seat sees them: off + // turn, the other side's listed moves are not the viewer's to play. + it('off turn, the listed moves are not the viewer’s to play', () => { + const { t } = mountWith(view(), {}, 'bob') + fireEvent.click(square('g6')) + expect(square('g6')).not.toHaveAttribute('aria-pressed') + fireEvent.click(square('f7')) + expect(t.play).not.toHaveBeenCalled() + }) + + it('off the hub, the board takes no taps', () => { + const { t } = mountWith(view(), {}, 'alice', false) + fireEvent.click(square('g6')) + fireEvent.click(square('f7')) + expect(t.play).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Resign' })).toBeDisabled() + }) + + it('a promotion asks which piece, with the question said and the queen in focus', () => { + const { t } = mountWith(view()) + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + expect(t.play).not.toHaveBeenCalled() + const picker = screen.getByRole('group', { name: 'promote to' }) + expect(within(picker).getByRole('button', { name: 'Queen' })).toHaveFocus() + expect(status()).toHaveTextContent('Choose a piece to promote to.') + fireEvent.click(within(picker).getByRole('button', { name: 'Knight' })) + expect(t.play).toHaveBeenCalledWith('e7e8n') + expect(screen.queryByRole('group', { name: 'promote to' })).toBeNull() + }) + + it('a promotion cancelled plays nothing and hands focus back to the pawn', () => { + const { t } = mountWith(view()) + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + fireEvent.keyDown(screen.getByRole('group', { name: 'promote to' }), { key: 'Escape' }) + expect(screen.queryByRole('group', { name: 'promote to' })).toBeNull() + expect(square('e7')).toHaveFocus() + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('group', { name: 'promote to' })).toBeNull() + expect(t.play).not.toHaveBeenCalled() + }) - it('runs the side to move’s clock down from the view, and only that one', () => { - mountWith(view()) - expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('3:00') - expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:55') - act(() => { - vi.advanceTimersByTime(5_000) + // A move made on the board instead abandons the promotion: the picker + // must not linger to send a second move for the same turn. + it('another move on the board drops a pending promotion', () => { + const { t } = mountWith(view()) + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + fireEvent.click(square('g6')) + expect(screen.queryByRole('group', { name: 'promote to' })).toBeNull() + fireEvent.click(square('f7')) + expect(vi.mocked(t.play).mock.calls).toEqual([['g6f7']]) + }) + + it('a new position drops a piece picked up and a promotion pending', () => { + const { rerender } = mountWith(view()) + fireEvent.click(square('g6')) + rerender(view()) + expect(square('g6')).toHaveAttribute('aria-pressed', 'false') + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + rerender(view()) + expect(screen.queryByRole('group', { name: 'promote to' })).toBeNull() }) - expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('2:55') - expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:55') }) - it('a finished game’s clocks stand still', () => { - mountWith(view({ phase: 'ended', currentPlayerId: undefined, sideToMove: undefined, legalMoves: [], result: { ending: 'resignation', winner: 'bob', winnerColor: 'black' } })) - act(() => { - vi.advanceTimersByTime(5_000) + describe('what the table says', () => { + it('names the opponent’s last move before the viewer’s turn', () => { + mountWith(view({ moves: ['a1a2', 'h7h8'] })) + expect(status()).toHaveTextContent('bob played h7 to h8. Your move.') + }) + + it('says check, and whose move it is off turn', () => { + const { unmount } = mountWith(view({ inCheck: true })) + expect(status()).toHaveTextContent('Check. Your move.') + unmount() + mountWith(view({ moves: ['a2a3'] }), {}, 'bob') + expect(status()).toHaveTextContent('alice to move.') + }) + + it('says a promotion by its piece', () => { + mountWith(view({ moves: ['a1a2', 'b2b1q'] })) + expect(status()).toHaveTextContent('bob played b2 to b1, promoting to a queen. Your move.') }) - expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('3:00') }) - it('resigning takes a second tap', () => { - const { t } = mountWith(view()) - fireEvent.click(screen.getByRole('button', { name: 'Resign' })) - expect(t.resign).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Confirm resign' })) - expect(t.resign).toHaveBeenCalledTimes(1) + describe('the clock', () => { + it('runs the side to move’s down from the view, and only that one', () => { + mountWith(view()) + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('3:00') + expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:55') + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('2:55') + expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:55') + }) + + it('starts again from each new view, on whichever side it names', () => { + const { rerender } = mountWith(view()) + act(() => { + vi.advanceTimersByTime(5_000) + }) + rerender(view({ sideToMove: 'black', currentPlayerId: 'bob', moves: ['g6f7'], clock: { whiteMs: 177_000, blackMs: 170_000, initialMs: 180_000, incrementMs: 2_000 } })) + expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:50') + act(() => { + vi.advanceTimersByTime(3_000) + }) + expect(screen.getByRole('timer', { name: 'bob’s clock' })).toHaveTextContent('2:47') + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('2:57') + }) + + it('stands still once the game is over', () => { + mountWith(ended({ result: { ending: 'resignation', winner: 'bob', winnerColor: 'black' } })) + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(screen.getByRole('timer', { name: 'alice’s clock' })).toHaveTextContent('3:00') + }) }) - it('a waiting table starts with the clock chosen', () => { - const { t } = mountWith(view({ phase: 'waiting', players: [{ playerId: 'alice' }, { playerId: 'bob' }], fen: undefined, legalMoves: [], clock: undefined, sideToMove: undefined, currentPlayerId: undefined })) - expect(screen.queryByRole('group', { name: 'board' })).toBeNull() - fireEvent.change(screen.getByRole('combobox', { name: 'Clock' }), { target: { value: '5+3' } }) - fireEvent.click(screen.getByRole('button', { name: 'Start' })) - expect(t.startTable).toHaveBeenCalledWith({ initialSeconds: 300, incrementSeconds: 3 }) + describe('resigning and leaving', () => { + it('resigning takes a second tap, and that tap is where focus goes', () => { + const { t } = mountWith(view()) + fireEvent.click(screen.getByRole('button', { name: 'Resign' })) + expect(t.resign).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Confirm resign' })).toHaveFocus() + fireEvent.click(screen.getByRole('button', { name: 'Confirm resign' })) + expect(t.resign).toHaveBeenCalledTimes(1) + }) + + it('thinking better of it hands focus back to Resign', () => { + const { t } = mountWith(view()) + fireEvent.click(screen.getByRole('button', { name: 'Resign' })) + fireEvent.click(screen.getByRole('button', { name: 'Keep playing' })) + expect(screen.getByRole('button', { name: 'Resign' })).toHaveFocus() + expect(t.resign).not.toHaveBeenCalled() + }) + + it('a new position drops a resignation half made', () => { + const { rerender } = mountWith(view()) + fireEvent.click(screen.getByRole('button', { name: 'Resign' })) + rerender(view()) + expect(screen.queryByRole('button', { name: 'Confirm resign' })).toBeNull() + }) + + // Leaving mid-game forfeits it: resigning is the one way to do that, + // and it asks first. + it('offers no one-tap leave while the game is on, only while waiting', () => { + const { unmount } = mountWith(view()) + expect(screen.queryByRole('button', { name: 'Leave table' })).toBeNull() + unmount() + const { t } = mountWith(waiting()) + fireEvent.click(screen.getByRole('button', { name: 'Leave table' })) + expect(t.leaveTable).toHaveBeenCalledTimes(1) + }) }) - it('a waiting table of one cannot start', () => { - mountWith(view({ phase: 'waiting', players: [{ playerId: 'alice' }], fen: undefined, legalMoves: [], clock: undefined })) - expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled() + describe('waiting', () => { + it('starts with the clock chosen', () => { + const { t } = mountWith(waiting()) + expect(screen.queryByRole('group', { name: 'board' })).toBeNull() + fireEvent.change(screen.getByRole('combobox', { name: 'Clock' }), { target: { value: '5+3' } }) + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + expect(t.startTable).toHaveBeenCalledWith({ initialSeconds: 300, incrementSeconds: 3 }) + }) + + it('cannot start with one seat, or off the hub', () => { + const { unmount } = mountWith(waiting({ players: [{ playerId: 'alice' }] })) + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled() + unmount() + mountWith(waiting(), {}, 'alice', false) + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled() + }) }) - it('an ended table says who won and offers another', () => { - const ended = view({ phase: 'ended', currentPlayerId: undefined, sideToMove: undefined, legalMoves: [], result: { ending: 'checkmate', winner: 'alice', winnerColor: 'white' } }) - const { t } = mountWith(ended, { ended: ended.result }) - expect(screen.getByText('You won by checkmate')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Resign' })).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) + describe('the ending', () => { + it('is said, and focus goes to another game', () => { + const { t, rerender } = mountWith(view()) + rerender(ended()) + expect(status()).toHaveTextContent('You won by checkmate') + expect(screen.queryByRole('button', { name: 'Resign' })).toBeNull() + expect(screen.getByRole('button', { name: 'Play again' })).toHaveFocus() + 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('holds Play again while another table is opening', () => { + mountWith(ended(), { opening: true }) + expect(screen.getByRole('button', { name: 'Opening…' })).toBeDisabled() + }) }) }) diff --git a/src/apps/chess/rules.ts b/src/apps/chess/rules.ts index e5abf7f5..c9cc3c80 100644 --- a/src/apps/chess/rules.ts +++ b/src/apps/chess/rules.ts @@ -48,16 +48,25 @@ export function movesTo(legal: string[], from: string, to: string): string[] { return legal.filter(uci => uci.slice(0, 2) === from && uci.slice(2, 4) === to) } +const PROMOTED: Record = { q: 'queen', r: 'rook', b: 'bishop', n: 'knight' } + +// A move as a sentence's tail: "h7 to h8", "b2 to b1, promoting to a queen". +export function describeMove(uci: string): string { + const promoted = PROMOTED[uci[4] ?? ''] + return `${uci.slice(0, 2)} to ${uci.slice(2, 4)}${promoted === undefined ? '' : `, promoting to a ${promoted}`}` +} + export function lastMoveSquares(moves: string[]): string[] { const last = moves[moves.length - 1] return last === undefined ? [] : [last.slice(0, 2), last.slice(2, 4)] } -// m:ss, rounded up so a clock reads zero only once it is gone; tenths -// under ten seconds, where they decide games. +// m:ss, or tenths under ten seconds, where they decide games; rounded up +// either way, so a clock reads zero only once it is gone. export function formatClock(ms: number): string { const left = Math.max(0, ms) - if (left < 10_000) return (Math.floor(left / 100) / 10).toFixed(1) + const tenths = Math.ceil(left / 100) + if (tenths < 100) return (tenths / 10).toFixed(1) const seconds = Math.ceil(left / 1000) return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` } diff --git a/src/apps/lobby/components/__tests__/LobbyGame.test.tsx b/src/apps/lobby/components/__tests__/LobbyGame.test.tsx index 6c12b38a..73cc00b6 100644 --- a/src/apps/lobby/components/__tests__/LobbyGame.test.tsx +++ b/src/apps/lobby/components/__tests__/LobbyGame.test.tsx @@ -32,7 +32,7 @@ const state = { castle: { view: null as CastleView | null, ended: null, selected: [] }, golf: { view: null as GameState | null, ended: null, peekCountdown: null }, rummy: { view: null as RummyView | null, ended: null, selected: [] }, - chess: { view: null as ChessView | null, ended: null, opening: false } + chess: { view: null as ChessView | null, opening: false } } as unknown as UseLobby vi.mock('@/hooks/useLobby', async importOriginal => ({ diff --git a/src/apps/stats/__tests__/rollup.test.ts b/src/apps/stats/__tests__/rollup.test.ts index 4d22382c..40720009 100644 --- a/src/apps/stats/__tests__/rollup.test.ts +++ b/src/apps/stats/__tests__/rollup.test.ts @@ -229,6 +229,14 @@ describe('rollupHubEvents', () => { expect(labels.sort()).toEqual(['Gin', 'Rummy']) }) + it('names chess', () => { + const labels = rollupHubEvents({ + days: 30, + rows: [{ date: '2026-09-30', event: 'game_started', variant: 'chess', surface: '', outcome: '', players: 2, events: 1 }], + }).variants.map(v => v.label) + expect(labels).toEqual(['Chess']) + }) + // game_finished's players is the seats still held — 1 for nearly every // abandonment — so only game_started may reach this table. A finish // folded in here would report a flood of one-player tables that were diff --git a/src/apps/stats/rollup.ts b/src/apps/stats/rollup.ts index 1e73c5f8..111aff0f 100644 --- a/src/apps/stats/rollup.ts +++ b/src/apps/stats/rollup.ts @@ -302,6 +302,7 @@ export const VARIANT_LABELS: Record = { castle: 'Castle', rummy: 'Rummy', gin: 'Gin', + chess: 'Chess', other: 'Other', } diff --git a/src/hooks/__tests__/useChessTable.test.tsx b/src/hooks/__tests__/useChessTable.test.tsx index 508ea69e..477f231a 100644 --- a/src/hooks/__tests__/useChessTable.test.tsx +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -32,15 +32,18 @@ describe('useChessTable', () => { return { ...hook, move, onLeft, showNotice, receive } } - it('holds the latest view, and the result once the game ends', () => { + // The ended view carries the result, and the hub sends it before + // gameEnded: the view is the table's one source of truth. + it('holds the latest view, and gameEnded changes nothing the view does not already say', () => { const { result, receive } = mount() receive({ gameJoined: { view: view({ phase: 'waiting' }) } }) expect(result.current.view?.phase).toBe('waiting') receive({ gameState: { view: view() } }) expect(result.current.view?.phase).toBe('playing') - expect(result.current.ended).toBeNull() + const over = view({ phase: 'ended', result: { ending: 'resignation', winner: 'alice', winnerColor: 'white' } }) + receive({ gameState: { view: over } }) receive({ gameEnded: { result: { ending: 'resignation', winner: 'alice', winnerColor: 'white' } } }) - expect(result.current.ended?.ending).toBe('resignation') + expect(result.current.view).toBe(over) }) it('a start names the clock in seconds', () => { diff --git a/src/hooks/__tests__/useLobby.test.tsx b/src/hooks/__tests__/useLobby.test.tsx index 96dab66d..4d92a239 100644 --- a/src/hooks/__tests__/useLobby.test.tsx +++ b/src/hooks/__tests__/useLobby.test.tsx @@ -250,6 +250,8 @@ describe('useLobby', () => { expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { startGame: { initialSeconds: 60, incrementSeconds: 0 } } } }) act(() => result.current.chess.play('e2e4')) expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { play: { uci: 'e2e4' } } } }) + act(() => result.current.chess.resign()) + expect(ws.lastSent()).toEqual({ event: 'chess', payload: { move: { resign: {} } } }) act(() => result.current.chess.playAgain()) act(() => ws.receive('commandRejected', { reason: 'leave your current game first' })) expect(result.current.chess.opening).toBe(false) diff --git a/src/hooks/useChessTable.ts b/src/hooks/useChessTable.ts index ce62ab62..be35e0f6 100644 --- a/src/hooks/useChessTable.ts +++ b/src/hooks/useChessTable.ts @@ -1,10 +1,11 @@ import { useCallback, useState } from 'react' -import type { ChessMoveName, ChessMovePayloads, ChessResult, ChessUpdate, ChessView } from '@/apps/chess/wire' +import type { ChessMoveName, ChessMovePayloads, ChessUpdate, ChessView } from '@/apps/chess/wire' // A chess table as the wire sends it, over the lobby's stream (useLobby). // The owner feeds handleUpdate every chess update and clears the table on -// a resume. The board's own selection is the component's: it lives and -// dies with one view. +// a resume. The view is the whole truth: an ended one carries the result, +// and arrives before gameEnded. The board's own selection is the +// component's: it lives and dies with one view. export interface ChessTableActions { // Seconds; absent is the hub's default. @@ -21,7 +22,6 @@ export interface UseChessTable extends ChessTableActions { createTable: () => void joinTable: (gameId: string) => void view: ChessView | null - ended: ChessResult | null // A table has been asked for and not yet arrived. opening: boolean handleUpdate: (update: ChessUpdate) => void @@ -38,12 +38,10 @@ export interface UseChessTableProps { export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTableProps): UseChessTable => { const [view, setView] = useState(null) - const [ended, setEnded] = useState(null) const [opening, setOpening] = useState(false) const clear = useCallback(() => { setView(null) - setEnded(null) setOpening(false) }, []) const handleRejected = useCallback(() => setOpening(false), []) @@ -52,7 +50,6 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa (update: ChessUpdate) => { if (update.gameJoined) { setView(update.gameJoined.view) - setEnded(null) setOpening(false) return } @@ -64,15 +61,11 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa if (update.gameCreated.createdBy !== playerId) showNotice(`${update.gameCreated.createdBy} opened chess table ${update.gameCreated.gameId}`) return } - if (update.gameEnded) { - setEnded(update.gameEnded.result) - return - } if (update.gameLeft) { clear() onLeft?.() } - // gameStarted and turnChanged: the view that follows says it all. + // gameStarted, turnChanged and gameEnded: the view says it all. }, [clear, onLeft, playerId, showNotice] ) @@ -96,5 +89,5 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa const play = useCallback((uci: string) => move('play', { uci }), [move]) const resign = useCallback(() => move('resign'), [move]) - return { view, ended, opening, handleUpdate, handleRejected, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign } + return { view, opening, handleUpdate, handleRejected, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign } } From d9e8352264d24e1b39f7cc0d1bd8a228ef860b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 20:16:59 +0000 Subject: [PATCH 3/3] chess: a1 is dark; inert squares off turn; result said once --- src/apps/chess/components/ChessTable.tsx | 9 +++++++-- .../components/__tests__/ChessTable.test.tsx | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index fd6d39b9..9880168a 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -207,7 +207,8 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { {squares.map((square, index) => { const piece = board.get(square) const mine = piece !== undefined && colorOfPiece(piece) === myColor - const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 1 + // 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) return (