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..4ebaaaab --- /dev/null +++ b/src/apps/chess/__tests__/rules.test.ts @@ -0,0 +1,114 @@ +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, 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') + }) +}) + +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..bce54b06 --- /dev/null +++ b/src/apps/chess/components/ChessTable.module.css @@ -0,0 +1,229 @@ +/* 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; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + color: white; +} + +.variant { + color: rgba(255, 255, 255, 0.7); + font-weight: 400; + font-size: 1rem; +} + +.danger { + padding: 0.75rem 1.5rem; + font-size: 1rem; + border-radius: 0.5rem; + border: none; + cursor: pointer; + background: #c0392b; + color: white; + font-weight: 600; + touch-action: manipulation; +} + +.danger:disabled { + opacity: 0.45; + cursor: default; +} + +.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 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: max(16rem, min(100%, calc(100dvh - 19rem))); + 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: 9cqi; + line-height: 1; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.light { + background-color: #eeeed2; +} + +.dark { + background-color: #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(246, 246, 105, 0.8); +} + +/* An image over the square's own colour, never in place of it. */ +.square[data-check='true'] { + 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. */ +.target::after { + content: ''; + position: absolute; + width: 30%; + height: 30%; + border-radius: 50%; + 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; +} + +.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); +} + +/* 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; + 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..9880168a --- /dev/null +++ b/src/apps/chess/components/ChessTable.tsx @@ -0,0 +1,343 @@ +import { useEffect, useId, useRef, useState } from 'react' +import type { ChessTableActions } from '@/hooks/useChessTable' +import felt from '@/apps/castle/components/CastleTable.module.css' +import type { ChessColor, ChessView } from '../wire' +import { describeMove, describeResult, formatClock, glyph, lastMoveSquares, movesTo, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' +import styles from './ChessTable.module.css' + +// 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 table's chrome is castle's, +// as rummy's is. + +export interface ChessTableProps { + playerId: string + connected: boolean + view: ChessView + table: ChessTableActions & { opening: boolean } +} + +// The clocks a starter can pick, as minutes + increment seconds. +const CLOCKS: Record = { + '1+0': { initialSeconds: 60, incrementSeconds: 0 }, + '3+2': { initialSeconds: 180, incrementSeconds: 2 }, + '5+3': { initialSeconds: 300, incrementSeconds: 3 }, + '10+5': { initialSeconds: 600, incrementSeconds: 5 } +} + +const 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 + +const colorOfPiece = (piece: string): ChessColor => (piece === piece.toUpperCase() ? 'white' : 'black') +const other = (color: ChessColor): ChessColor => (color === 'white' ? 'black' : 'white') + +// One seat's clock. The hub stamps each view with the time left as it +// built it, so the running side counts down from the moment its view +// arrived. Its own component, so the tick repaints the clock and not the +// board. +const ClockRow = ({ view, seatId, color, you }: { view: ChessView; seatId: string; color: ChessColor; you: boolean }) => { + const [arrived, setArrived] = useState(() => ({ view, at: Date.now() })) + const [now, setNow] = useState(() => Date.now()) + if (arrived.view !== view) setArrived({ view, at: Date.now() }) + const running = view.phase === 'playing' && view.sideToMove === color + useEffect(() => { + if (!running) return + const timer = window.setInterval(() => setNow(Date.now()), TICK_MS) + return () => window.clearInterval(timer) + }, [running]) + const stamped = view.clock === undefined ? 0 : color === 'white' ? view.clock.whiteMs : view.clock.blackMs + const ms = running ? stamped - Math.max(0, now - arrived.at) : stamped + return ( +
+ + {you ? `${seatId} (you)` : seatId} · {color} + + + {formatClock(ms)} + +
+ ) +} + +const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { + const { opening } = table + const headingRef = useRef(null) + const boardRef = useRef(null) + const resignRef = useRef(null) + const confirmRef = useRef(null) + const playAgainRef = useRef(null) + // 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, the promotion waiting on a piece, and a + // resignation half made — each keyed to the view it was made against, + // so a new position drops it rather than acting on one that is gone. + const [picked, setPicked] = useState<{ view: ChessView; square: string } | null>(null) + const [promoting, setPromoting] = useState<{ view: ChessView; from: string; moves: string[] } | null>(null) + const [confirmResign, setConfirmResign] = useState(null) + const [clockChoice, setClockChoice] = useState('3+2') + + const me = view.players.find(player => player.playerId === playerId) + const myColor: ChessColor = me?.color ?? 'white' + const opponent = view.players.find(player => player.playerId !== playerId) + const myTurn = view.phase === 'playing' && view.currentPlayerId === playerId + const from = picked?.view === view ? picked.square : null + const pendingPromotion = promoting?.view === view ? promoting : null + const targets = from === null ? [] : targetsFrom(view.legalMoves, from) + const board = readBoard(view.fen) + const last = lastMoveSquares(view.moves) + // The king of the side to move — or, once mated, of the side that was. + const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null + + const 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, from, moves }) + return + } + // Another of the viewer's pieces with a move picks it up; anything + // else lets go. + const movable = targetsFrom(view.legalMoves, square).length > 0 + setPicked(movable && square !== from ? { view, square } : null) + } + + const 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 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 squares = squaresFor(myColor) + + return ( +
+
+

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

+

+ {status} +

+ {/* Leaving a game in play forfeits it: Resign is that, and it asks + first. Before the start, leaving costs nothing. */} + {view.phase === 'waiting' && ( + + )} +
+ + {view.phase === 'waiting' ? ( +
+
    + {view.players.map(player => ( +
  • {player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}
  • + ))} +
+ + +
+ ) : ( +
+ {opponent?.color !== undefined && } + + a move + +
+ {squares.map((square, index) => { + const piece = board.get(square) + const mine = piece !== undefined && colorOfPiece(piece) === myColor + // a1 dark: a square is dark where its file and rank index sum even. + const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0 + const target = targets.includes(square) + return ( + + ) + })} +
+ {me?.color !== undefined && } + + {pendingPromotion !== null && ( +
{ + 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 ( + + ) + })} + +
+ )} + + {view.phase === 'playing' && me !== undefined && ( +
+ {confirmResign === view ? ( + <> + + + + ) : ( + + )} +
+ )} + + {view.phase === 'ended' && ( +
+ +
+ + +
+
+ )} +
+ )} +
+ ) +} + +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..232c8deb --- /dev/null +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -0,0 +1,360 @@ +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, 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 => ({ + 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 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'] => ({ + 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', connected = true) => { + const t = table(over) + 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(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + 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 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('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']) + }) + + // a1 is dark, as on every board; so is h8, and d1 and e4 are light. + it('colors the squares as a chessboard is colored', () => { + mountWith(view()) + expect(square('a1').dataset.shade).toBe('dark') + expect(square('h8').dataset.shade).toBe('dark') + expect(square('d1').dataset.shade).toBe('light') + expect(square('e4').dataset.shade).toBe('light') + }) + + it('marks the squares inert off turn, and still focusable', () => { + const { unmount } = mountWith(view()) + expect(square('g6')).not.toHaveAttribute('aria-disabled', 'true') + unmount() + mountWith(view(), {}, 'bob') + expect(square('g6')).toHaveAttribute('aria-disabled', 'true') + expect(square('g6')).not.toBeDisabled() + }) + + 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('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() + }) + }) + + 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('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() + }) + + // 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() + }) + }) + + 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.') + }) + }) + + 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') + }) + }) + + 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) + }) + }) + + 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() + }) + }) + + 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') + // Said once: the panel's copy is for the eye, not read again. + expect(screen.getByText('You won by checkmate', { selector: 'p:not([data-testid])' })).toHaveAttribute('aria-hidden', 'true') + expect(screen.queryByRole('button', { name: 'Resign' })).toBeNull() + expect(screen.getByRole('button', { name: 'Play again' })).toHaveFocus() + 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 new file mode 100644 index 00000000..c9cc3c80 --- /dev/null +++ b/src/apps/chess/rules.ts @@ -0,0 +1,123 @@ +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) +} + +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, 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) + 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')}` +} + +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..73cc00b6 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, 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/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 new file mode 100644 index 00000000..477f231a --- /dev/null +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -0,0 +1,100 @@ +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 } + } + + // 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') + 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.view).toBe(over) + }) + + 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..4d92a239 100644 --- a/src/hooks/__tests__/useLobby.test.tsx +++ b/src/hooks/__tests__/useLobby.test.tsx @@ -236,6 +236,35 @@ 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.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) + 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..be35e0f6 --- /dev/null +++ b/src/hooks/useChessTable.ts @@ -0,0 +1,93 @@ +import { useCallback, useState } from 'react' +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 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. + 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 + // 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 [opening, setOpening] = useState(false) + + const clear = useCallback(() => { + setView(null) + setOpening(false) + }, []) + const handleRejected = useCallback(() => setOpening(false), []) + + const handleUpdate = useCallback( + (update: ChessUpdate) => { + if (update.gameJoined) { + setView(update.gameJoined.view) + 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.gameLeft) { + clear() + onLeft?.() + } + // gameStarted, turnChanged and gameEnded: the view 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, 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':