Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions src/apps/chess/components/ChessTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
50 changes: 31 additions & 19 deletions src/apps/chess/components/ChessTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -154,21 +161,24 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {

return (
<div className={styles.table} data-phase={view.phase}>
<div className={felt.tableHeader}>
<h1 ref={headingRef} tabIndex={-1} className={felt.title}>
Chess {view.gameId}
{view.variant === 'kpk' && <span className={styles.variant}> · king and pawn</span>}
</h1>
<p className={felt.hint} role="status" data-testid="chess-status">
{status}
</p>
{/* Leaving a game in play forfeits it: Resign is that, and it asks
first. Before the start, leaving costs nothing. */}
{view.phase === 'waiting' && (
<button type="button" className={felt.link} onClick={table.leaveTable} disabled={!connected}>
Leave table
</button>
)}
<div className={styles.top}>
<div className={felt.tableHeader}>
<h1 ref={headingRef} tabIndex={-1} className={felt.title}>
Chess {view.gameId}
{view.variant === 'kpk' && <span className={styles.variant}> · king and pawn</span>}
</h1>
<p className={felt.hint} role="status" data-testid="chess-status">
{status}
</p>
{/* Leaving a game in play forfeits it: Resign is that, and it asks
first. Before the start, leaving costs nothing. */}
{view.phase === 'waiting' && (
<button type="button" className={felt.link} onClick={table.leaveTable} disabled={!connected}>
Leave table
</button>
)}
</div>
<ScoreSheet view={view} playerId={playerId} />
</div>

{view.phase === 'waiting' ? (
Expand Down Expand Up @@ -319,17 +329,19 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
</div>
)}

{view.phase === 'ended' && (
{(view.phase === 'ended' || view.phase === 'closed') && (
<div className={styles.ending}>
<p className={styles.result} aria-hidden="true">
{status}
</p>
{/* Between games the table is still both seats': the next game
is played here. Once a seat has left, another table. */}
<div className={styles.actions}>
<button ref={playAgainRef} type="button" className={felt.primary} onClick={table.playAgain} disabled={!connected || opening}>
{opening ? 'Opening…' : 'Play again'}
{opening ? 'Opening…' : view.phase === 'ended' ? 'Next game' : 'Play again'}
</button>
<button type="button" className={felt.secondary} onClick={table.leaveTable}>
Back to the room
{view.phase === 'ended' ? 'Leave table' : 'Back to the room'}
</button>
</div>
</div>
Expand Down
65 changes: 65 additions & 0 deletions src/apps/chess/components/ScoreSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.notepad}>
<table className={styles.notepadSheet} aria-label="Score sheet">
<thead>
<tr>
<th scope="col">#</th>
{columns.map(id => (
<th key={id} scope="col" title={id}>
<span className={styles.notepadName}>{id === playerId ? 'you' : id}</span>
</th>
))}
</tr>
</thead>
<tbody>
{lines.slice(first).map((line, i) => (
<tr key={first + i}>
<th scope="row">{first + i + 1}</th>
{line.winner === undefined ? (
<td colSpan={columns.length} className={styles.notepadDraw}>
draw
</td>
) : (
columns.map(id => <td key={id}>{id === line.winner ? 1 : <span aria-hidden="true">—</span>}</td>)
)}
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
{columns.map(id => (
<td key={id}>{wins(id)}</td>
))}
</tr>
</tfoot>
</table>
</div>
)
}

export default ScoreSheet
53 changes: 50 additions & 3 deletions src/apps/chess/components/__tests__/ChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
})
})
12 changes: 11 additions & 1 deletion src/apps/chess/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
28 changes: 22 additions & 6 deletions src/hooks/__tests__/useChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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']])
Expand Down
Loading
Loading