diff --git a/README.md b/README.md index c87e587..1a404ec 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state room stream's game envelopes. Castle and rummy share the felt and the card faces (`castle/components/Cards.tsx`, `CastleTable.module.css`). `ChessTable` and `useChessTable` are chess's: two seats, the board from the viewer's side, the moves the hub lists for the -side to move (the client holds no rules), and the clocks counted down from each view. Share links are +side to move (the client holds no rules), the clocks counted down from each view, and a +score notepad as the table plays on: Next game starts another there, sides swapped. Share links are `/games/room/:roomId` and `/games/room/:roomId/table/:gameId`; the old `/golf` and `/castle` links redirect to them, and `/thoughts` to `/games`. Hiding the panel is how the bare world is asked for, so that choice is remembered across visits. diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css index 97a58f4..f49b225 100644 --- a/src/apps/chess/components/ChessTable.module.css +++ b/src/apps/chess/components/ChessTable.module.css @@ -228,3 +228,63 @@ font-size: 1.15rem; color: #f0c96a; } + +/* The header, and the score sheet beside it once there is one. */ +.top { + display: flex; + gap: 0.75rem; + align-items: flex-start; + justify-content: space-between; +} + +/* The score sheet: rummy's notepad, ruled like paper and written in a + hand, set at the side of the table rather than pinned over a felt. */ +.notepad { + flex: none; + width: max-content; + padding: 0.4rem 0.6rem 0.5rem 1.3rem; + border-radius: 2px; + background-color: #fdf8e2; + background-image: + linear-gradient(to right, transparent 0.95rem, rgba(214, 69, 65, 0.55) 0.95rem, rgba(214, 69, 65, 0.55) calc(0.95rem + 1px), transparent calc(0.95rem + 1px)), + repeating-linear-gradient(to bottom, transparent 0, transparent 1.2rem, rgba(80, 120, 190, 0.28) 1.2rem, rgba(80, 120, 190, 0.28) calc(1.2rem + 1px)); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35); + transform: rotate(-1.5deg); + color: #2a2a33; + font-family: 'Bradley Hand', 'Segoe Print', 'Comic Sans MS', cursive; + font-size: 0.8rem; + line-height: 1.2rem; +} + +.notepadSheet { + border-collapse: collapse; +} + +.notepadSheet th, +.notepadSheet td { + padding: 0 0.35rem; + text-align: right; + font-weight: normal; + white-space: nowrap; +} + +.notepadName { + display: inline-block; + max-width: 4.5rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + font-weight: 700; +} + +.notepadSheet tfoot td, +.notepadSheet tfoot th { + border-top: 2px solid #2a2a33; + font-weight: 700; +} + +.notepadSheet td.notepadDraw { + text-align: center; + font-style: italic; + opacity: 0.7; +} diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index 9880168..a8899dc 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -4,6 +4,7 @@ import felt from '@/apps/castle/components/CastleTable.module.css' import type { ChessColor, ChessView } from '../wire' import { describeMove, describeResult, formatClock, glyph, lastMoveSquares, movesTo, pieceName, readBoard, squaresFor, targetsFrom } from '../rules' import styles from './ChessTable.module.css' +import ScoreSheet from './ScoreSheet' // The board from the viewer's chair: their side at the bottom, the // opponent's clock above it and their own below. A tap on a piece offers @@ -90,7 +91,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { }) // The game's end is where the next thing to do is: another game. useEffect(() => { - if (view.phase === 'ended') playAgainRef.current?.focus() + if (view.phase === 'ended' || view.phase === 'closed') playAgainRef.current?.focus() }, [view.phase]) const targetNote = useId() @@ -140,6 +141,12 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const status = (() => { if (view.phase === 'waiting') return view.players.length < 2 ? 'Waiting for a second seat.' : 'Pick a clock and start.' if (view.phase === 'ended') return view.result === undefined ? '' : describeResult(view.result, playerId) + if (view.phase === 'closed') { + // A leave mid-game is that game's result; between games, the news. + if (view.result?.ending === 'abandoned') return describeResult(view.result, playerId) + const left = view.players.find(player => player.playerId !== playerId) + return `${left?.playerId ?? 'Your opponent'} left the table.` + } if (pendingPromotion !== null) return 'Choose a piece to promote to.' if (!myTurn) return `${view.currentPlayerId ?? ''} to move.` // The move just made is the opponent's: say it, since the board only @@ -154,21 +161,24 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { 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' && ( - - )} +
+
+

+ 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' ? ( @@ -319,17 +329,19 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
)} - {view.phase === 'ended' && ( + {(view.phase === 'ended' || view.phase === 'closed') && (
+ {/* Between games the table is still both seats': the next game + is played here. Once a seat has left, another table. */}
diff --git a/src/apps/chess/components/ScoreSheet.tsx b/src/apps/chess/components/ScoreSheet.tsx new file mode 100644 index 0000000..c263284 --- /dev/null +++ b/src/apps/chess/components/ScoreSheet.tsx @@ -0,0 +1,65 @@ +import type { ChessView } from '../wire' +import styles from './ChessTable.module.css' + +// The table's running score as a notepad, rummy's: a column a player, a +// line a game with a 1 in its winner's column, and the wins totalled under +// a rule. The page holds the last few games, so a long match's sheet never +// pushes the board off the screen; the totals count them all. + +const PAGE = 5 + +export interface ScoreSheetProps { + view: ChessView + playerId: string +} + +const ScoreSheet = ({ view, playerId }: ScoreSheetProps) => { + const lines = view.scoreSheet ?? [] + if (lines.length === 0) return null + const columns = view.players.map(player => player.playerId) + for (const line of lines) { + if (line.winner !== undefined && !columns.includes(line.winner)) columns.push(line.winner) + } + const first = Math.max(0, lines.length - PAGE) + const wins = (id: string) => lines.filter(line => line.winner === id).length + return ( +
+ + + + + {columns.map(id => ( + + ))} + + + + {lines.slice(first).map((line, i) => ( + + + {line.winner === undefined ? ( + + ) : ( + columns.map(id => ) + )} + + ))} + + + + + {columns.map(id => ( + + ))} + + +
# + {id === playerId ? 'you' : id} +
{first + i + 1} + draw + {id === line.winner ? 1 : }
Total{wins(id)}
+
+ ) +} + +export default ScoreSheet diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index 232c8de..b8e505a 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -338,23 +338,70 @@ describe('ChessTable', () => { }) describe('the ending', () => { - it('is said, and focus goes to another game', () => { + it('is said, and focus goes to the next game at this table', () => { const { t, rerender } = mountWith(view()) rerender(ended()) expect(status()).toHaveTextContent('You won by checkmate') // Said once: the panel's copy is for the eye, not read again. expect(screen.getByText('You won by checkmate', { selector: 'p:not([data-testid])' })).toHaveAttribute('aria-hidden', 'true') expect(screen.queryByRole('button', { name: 'Resign' })).toBeNull() - expect(screen.getByRole('button', { name: 'Play again' })).toHaveFocus() + expect(screen.getByRole('button', { name: 'Next game' })).toHaveFocus() + fireEvent.click(screen.getByRole('button', { name: 'Next game' })) + expect(t.playAgain).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Leave table' })) + expect(t.leaveTable).toHaveBeenCalledTimes(1) + }) + + it('once the opponent leaves between games, says so and offers another table', () => { + const { t } = mountWith(ended({ phase: 'closed' })) + expect(status()).toHaveTextContent('bob left the table.') + expect(screen.queryByRole('button', { name: 'Next game' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Play again' })) expect(t.playAgain).toHaveBeenCalledTimes(1) fireEvent.click(screen.getByRole('button', { name: 'Back to the room' })) expect(t.leaveTable).toHaveBeenCalledTimes(1) }) + it('a leave mid-game is the result itself', () => { + mountWith(ended({ phase: 'closed', result: { ending: 'abandoned', winner: 'alice', winnerColor: 'white' } })) + expect(status()).toHaveTextContent('You won: your opponent left') + }) + it('holds Play again while another table is opening', () => { - mountWith(ended(), { opening: true }) + mountWith(ended({ phase: 'closed' }), { opening: true }) expect(screen.getByRole('button', { name: 'Opening…' })).toBeDisabled() }) }) + + describe('the score sheet', () => { + it('is not there before a game has finished', () => { + mountWith(view()) + expect(screen.queryByRole('table', { name: 'Score sheet' })).toBeNull() + }) + + it('marks each game’s winner, a draw for neither, and totals the wins', () => { + mountWith( + view({ + scoreSheet: [ + { winner: 'alice', ending: 'checkmate' }, + { ending: 'stalemate' }, + { winner: 'bob', ending: 'resignation' }, + { winner: 'alice', ending: 'timeout' } + ] + }) + ) + const sheet = screen.getByRole('table', { name: 'Score sheet' }) + const rows = within(sheet).getAllByRole('row').map(row => row.textContent) + expect(rows).toEqual(['#youbob', '11—', '2draw', '3—1', '41—', 'Total21']) + }) + + it('pages the last five games and totals them all', () => { + const won = (winner: string) => ({ winner, ending: 'checkmate' as const }) + mountWith(view({ scoreSheet: [won('alice'), won('alice'), won('bob'), won('alice'), won('bob'), won('bob'), won('alice')] })) + const rows = within(screen.getByRole('table', { name: 'Score sheet' })) + .getAllByRole('row') + .map(row => row.textContent) + expect(rows).toEqual(['#youbob', '3—1', '41—', '5—1', '6—1', '71—', 'Total43']) + }) + }) }) diff --git a/src/apps/chess/wire.ts b/src/apps/chess/wire.ts index 871c79c..8a97a2f 100644 --- a/src/apps/chess/wire.ts +++ b/src/apps/chess/wire.ts @@ -5,7 +5,9 @@ export type ChessColor = 'white' | 'black' -export type ChessPhase = 'waiting' | 'playing' | 'ended' +// ended: a game is over and the table waits on the next; closed: a seat +// left the table. +export type ChessPhase = 'waiting' | 'playing' | 'ended' | 'closed' export type ChessEnding = | 'checkmate' @@ -39,6 +41,12 @@ export interface ChessResult { ending: ChessEnding } +// One finished game: the winning player, absent for a draw. +export interface ChessScoreLine { + winner?: string + ending: ChessEnding +} + export interface ChessView { gameId: string phase: ChessPhase @@ -55,6 +63,8 @@ export interface ChessView { legalMoves: string[] clock?: ChessClock result?: ChessResult + // Every game the table finished, in order. Absent from a hub before tables played on. + scoreSheet?: ChessScoreLine[] } // The chess update union's JSON encoding: exactly one member present. diff --git a/src/hooks/__tests__/useChessTable.test.tsx b/src/hooks/__tests__/useChessTable.test.tsx index 477f231..ee4d450 100644 --- a/src/hooks/__tests__/useChessTable.test.tsx +++ b/src/hooks/__tests__/useChessTable.test.tsx @@ -60,16 +60,20 @@ describe('useChessTable', () => { expect(move.mock.calls).toEqual([['play', { uci: 'e7e8q' }], ['resign']]) }) - it('leaving a game in play asks the hub; leaving an ended one only clears', () => { + // A table outlives its games: leaving one in play or between games asks + // the hub; only a closed table is gone already. + it('leaving a table asks the hub; leaving a closed one only clears', () => { const { result, receive, move, onLeft } = mount() receive({ gameJoined: { view: view() } }) act(() => result.current.leaveTable()) - expect(move.mock.calls).toEqual([['leaveGame']]) + receive({ gameState: { view: view({ phase: 'ended' }) } }) + act(() => result.current.leaveTable()) + expect(move.mock.calls).toEqual([['leaveGame'], ['leaveGame']]) expect(result.current.view).not.toBeNull() - receive({ gameState: { view: view({ phase: 'ended' }) } }) + receive({ gameState: { view: view({ phase: 'closed' }) } }) act(() => result.current.leaveTable()) - expect(move.mock.calls).toHaveLength(1) + expect(move.mock.calls).toHaveLength(2) expect(result.current.view).toBeNull() expect(onLeft).toHaveBeenCalledTimes(1) }) @@ -82,8 +86,20 @@ describe('useChessTable', () => { expect(onLeft).toHaveBeenCalledTimes(1) }) - it('play again opens a table, and a refusal lets it be asked again', () => { - const { result, move } = mount() + it('play again at an open table is its next game, on the same clock', () => { + const { result, receive, move } = mount() + receive({ gameState: { view: view({ phase: 'ended' }) } }) + act(() => result.current.playAgain()) + expect(move.mock.calls).toEqual([['startGame', { initialSeconds: 180, incrementSeconds: 2 }]]) + // Held until the next game's view arrives, so a second tap cannot ask twice. + expect(result.current.opening).toBe(true) + receive({ gameState: { view: view() } }) + expect(result.current.opening).toBe(false) + }) + + it('play again from a closed table opens another, and a refusal lets it be asked again', () => { + const { result, receive, move } = mount() + receive({ gameState: { view: view({ phase: 'closed' }) } }) act(() => result.current.playAgain()) expect(result.current.opening).toBe(true) expect(move.mock.calls).toEqual([['createGame']]) diff --git a/src/hooks/useChessTable.ts b/src/hooks/useChessTable.ts index be35e0f..a4a906c 100644 --- a/src/hooks/useChessTable.ts +++ b/src/hooks/useChessTable.ts @@ -11,8 +11,8 @@ export interface ChessTableActions { // Seconds; absent is the hub's default. startTable: (clock?: ChessMovePayloads['startGame']) => void leaveTable: () => void - // Another table, from the one that just ended: a create, since the - // finished game is already gone from the hub. + // Another game: the table's next, on its clock, while it is open; a new + // table once it has closed. playAgain: () => void play: (uci: string) => void resign: () => void @@ -22,7 +22,7 @@ export interface UseChessTable extends ChessTableActions { createTable: () => void joinTable: (gameId: string) => void view: ChessView | null - // A table has been asked for and not yet arrived. + // Another game has been asked for and not yet arrived. opening: boolean handleUpdate: (update: ChessUpdate) => void handleRejected: () => void @@ -55,6 +55,7 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa } if (update.gameState) { setView(update.gameState.view) + setOpening(false) return } if (update.gameCreated) { @@ -71,18 +72,24 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa ) const createTable = useCallback(() => move('createGame'), [move]) + // Either way held until the hub answers: the next game's view, or the + // new table's. const playAgain = useCallback(() => { setOpening(true) + if (view?.phase === 'ended' && view.clock !== undefined) { + move('startGame', { initialSeconds: view.clock.initialMs / 1000, incrementSeconds: view.clock.incrementMs / 1000 }) + return + } move('createGame') - }, [move]) + }, [move, view]) const joinTable = useCallback((gameId: string) => move('joinGame', { gameId }), [move]) const startTable = useCallback((clock: ChessMovePayloads['startGame'] = {}) => move('startGame', clock), [move]) const leaveTable = useCallback(() => { - if (view !== null && view.phase !== 'ended') { + if (view !== null && view.phase !== 'closed') { move('leaveGame') return } - // An ended table is already gone from the hub: only the view lingers. + // A closed table is already gone from the hub: only the view lingers. clear() onLeft?.() }, [clear, move, onLeft, view])