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
16 changes: 16 additions & 0 deletions src/apps/chess/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
nameOf,
glyph,
describeResult,
formatClock,
Expand Down Expand Up @@ -126,3 +127,18 @@ describe('describeResult', () => {
expect(describeResult({ ending: 'timeout' }, 'alice')).toBe('Draw: time ran out, and a bare king cannot win')
})
})

// A bot's seat is named for its engine and strength; a player's id is
// already their name.
describe('nameOf', () => {
it('reads a bot’s seat as its engine and strength', () => {
expect(nameOf('stockfish@1500')).toBe('Stockfish 1500')
expect(nameOf('bouncy-coral-quokka-x9k2')).toBe('bouncy-coral-quokka-x9k2')
})

it('names a bot that won', () => {
expect(describeResult({ winner: 'stockfish@1500', winnerColor: 'white', ending: 'checkmate' }, 'alice')).toBe(
'Stockfish 1500 won by checkmate'
)
})
})
41 changes: 33 additions & 8 deletions src/apps/chess/components/ChessTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'
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 { describeMove, describeResult, formatClock, glyph, lastMoveSquares, movesTo, nameOf, pieceName, readBoard, squaresFor, targetsFrom } from '../rules'
import styles from './ChessTable.module.css'
import ScoreSheet from './ScoreSheet'

Expand All @@ -18,7 +18,7 @@ export interface ChessTableProps {
playerId: string
connected: boolean
view: ChessView
table: ChessTableActions & { opening: boolean }
table: ChessTableActions & { opening: boolean; seating: boolean }
}

// The clocks a starter can pick, as minutes + increment seconds.
Expand All @@ -29,6 +29,15 @@ const CLOCKS: Record<string, { initialSeconds: number; incrementSeconds: number
'10+5': { initialSeconds: 600, incrementSeconds: 5 }
}

// A bot's strengths, as Elo on Stockfish's scale (1320 to 3190).
const BOT_STRENGTHS: Array<{ elo: number; name: string }> = [
{ elo: 1320, name: 'Beginner' },
{ elo: 1600, name: 'Casual' },
{ elo: 1900, name: 'Club' },
{ elo: 2300, name: 'Strong' },
{ elo: 3190, name: 'Full strength' }
]

const PROMOTIONS: Array<{ letter: string; name: string }> = [
{ letter: 'q', name: 'Queen' },
{ letter: 'r', name: 'Rook' },
Expand Down Expand Up @@ -64,9 +73,9 @@ const ClockRow = ({ view, seatId, color, you }: { view: ChessView; seatId: strin
return (
<div className={`${styles.clockRow} ${running ? styles.running : ''}`}>
<span className={styles.clockName}>
{you ? `${seatId} (you)` : seatId} · {color}
{you ? `${nameOf(seatId)} (you)` : nameOf(seatId)} · {color}
</span>
<span role="timer" aria-label={`${seatId}’s clock`} className={`${styles.clock} ${ms < 10_000 ? styles.low : ''}`}>
<span role="timer" aria-label={`${nameOf(seatId)}’s clock`} className={`${styles.clock} ${ms < 10_000 ? styles.low : ''}`}>
{formatClock(ms)}
</span>
</div>
Expand Down Expand Up @@ -107,6 +116,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
const [promoting, setPromoting] = useState<{ view: ChessView; from: string; to: string; moves: string[] } | null>(null)
const [confirmResign, setConfirmResign] = useState<ChessView | null>(null)
const [clockChoice, setClockChoice] = useState('3+2')
const [botElo, setBotElo] = useState(BOT_STRENGTHS[1].elo)

const me = view.players.find(player => player.playerId === playerId)
const myColor: ChessColor = me?.color ?? 'white'
Expand Down Expand Up @@ -215,15 +225,15 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
// 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.`
return `${left === undefined ? 'Your opponent' : nameOf(left.playerId)} left the table.`
}
if (pendingPromotion !== null) return 'Choose a piece to promote to.'
if (!myTurn) return `${view.currentPlayerId ?? ''} to move.`
if (!myTurn) return `${nameOf(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)}. ` : ''
const played = lastMove !== undefined && lastMover !== undefined ? `${nameOf(lastMover.playerId)} played ${describeMove(lastMove)}. ` : ''
return `${played}${view.inCheck ? 'Check. ' : ''}Your move.`
})()

Expand Down Expand Up @@ -255,9 +265,24 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
<div className={styles.waiting}>
<ul className={styles.seats}>
{view.players.map(player => (
<li key={player.playerId}>{player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}</li>
<li key={player.playerId}>{player.playerId === playerId ? `${player.playerId} (you)` : nameOf(player.playerId)}</li>
))}
</ul>
{/* Alone at the table, a bot can take the other chair. */}
{view.players.length === 1 && (
<div className={styles.clockPick}>
<select aria-label="Bot strength" value={botElo} onChange={event => setBotElo(Number(event.target.value))}>
{BOT_STRENGTHS.map(({ elo, name }) => (
<option key={elo} value={elo}>
{name} ({elo})
</option>
))}
</select>
<button type="button" className={felt.secondary} onClick={() => table.addBot(botElo)} disabled={!connected || table.seating}>
Add a bot
</button>
</div>
)}
<label className={styles.clockPick}>
Clock
<select value={clockChoice} onChange={event => setClockChoice(event.target.value)}>
Expand Down
5 changes: 3 additions & 2 deletions src/apps/chess/components/ScoreSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { nameOf } from '../rules'
import type { ChessView } from '../wire'
import styles from './ChessTable.module.css'

Expand Down Expand Up @@ -29,8 +30,8 @@ const ScoreSheet = ({ view, playerId }: ScoreSheetProps) => {
<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 key={id} scope="col" title={nameOf(id)}>
<span className={styles.notepadName}>{id === playerId ? 'you' : nameOf(id)}</span>
</th>
))}
</tr>
Expand Down
45 changes: 45 additions & 0 deletions src/apps/chess/components/__tests__/ChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ const waiting = (over: Partial<ChessView> = {}): ChessView =>

const table = (over: Partial<ChessTableProps['table']> = {}): ChessTableProps['table'] => ({
opening: false,
seating: false,
startTable: vi.fn(),
leaveTable: vi.fn(),
playAgain: vi.fn(),
play: vi.fn(),
resign: vi.fn(),
addBot: vi.fn(),
...over
})

Expand Down Expand Up @@ -500,6 +502,49 @@ describe('ChessTable', () => {
})
})

describe('a bot', () => {
it('is offered to a player alone at the table, at the strength they pick', () => {
const { t } = mountWith(waiting({ players: [{ playerId: 'alice' }] }))
const strength = screen.getByRole('combobox', { name: 'Bot strength' })
fireEvent.change(strength, { target: { value: '1900' } })
fireEvent.click(screen.getByRole('button', { name: 'Add a bot' }))
expect(t.addBot).toHaveBeenCalledWith(1900)
})

it('is held while one is being seated, so a double tap asks once', () => {
mountWith(waiting({ players: [{ playerId: 'alice' }] }), { seating: true })
expect(screen.getByRole('button', { name: 'Add a bot' })).toBeDisabled()
})

it('is not offered once the second seat is taken, or off the hub', () => {
const { unmount } = mountWith(waiting())
expect(screen.queryByRole('button', { name: 'Add a bot' })).toBeNull()
unmount()
mountWith(waiting({ players: [{ playerId: 'alice' }] }), {}, 'alice', false)
expect(screen.getByRole('button', { name: 'Add a bot' })).toBeDisabled()
})

it('is named for its engine and strength wherever a player is', () => {
const { unmount } = mountWith(waiting({ players: [{ playerId: 'alice' }, { playerId: 'stockfish@1500', bot: true }] }))
expect(screen.getByText('Stockfish 1500')).toBeInTheDocument()
unmount()
mountWith(
view({
players: [
{ playerId: 'alice', color: 'white' },
{ playerId: 'stockfish@1500', color: 'black', bot: true }
],
sideToMove: 'black',
currentPlayerId: 'stockfish@1500',
scoreSheet: [{ winner: 'stockfish@1500', ending: 'checkmate' }]
})
)
expect(status()).toHaveTextContent('Stockfish 1500 to move.')
expect(screen.getByText(/Stockfish 1500 · black/)).toBeInTheDocument()
expect(within(screen.getByRole('table', { name: 'Score sheet' })).getByText('Stockfish 1500')).toBeInTheDocument()
})
})

describe('the ending', () => {
it('is said, and focus goes to the next game at this table', () => {
const { t, rerender } = mountWith(view())
Expand Down
13 changes: 11 additions & 2 deletions src/apps/chess/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,18 @@ const DRAWN_BY: Record<ChessEnding, string> = {
abandoned: 'Draw'
}

// A seat as people read it: a player's id is their name; a bot's
// ("stockfish@1500") its engine and strength. The id's shape is the hub's
// bot-naming contract (games_hub chess_bots.h); a new engine extends it here.
export function nameOf(playerId: string): string {
const bot = /^stockfish@(\d+)$/.exec(playerId)
return bot === null ? playerId : `Stockfish ${bot[1]}`
}

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]}`
const winner = nameOf(result.winner)
if (result.ending === 'abandoned') return mine ? 'You won: your opponent left' : `${winner} won: their opponent left`
return `${mine ? 'You' : winner} won ${WON_BY[result.ending]}`
}
4 changes: 4 additions & 0 deletions src/apps/chess/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ChessPlayer {
playerId: string
// Absent while waiting.
color?: ChessColor
// A bot's seat, its id naming engine and strength ("stockfish@1500").
bot?: boolean
}

// Each side's time left as of the moment the hub built the view; the
Expand Down Expand Up @@ -86,6 +88,8 @@ export interface ChessMovePayloads {
leaveGame: undefined
play: { uci: string }
resign: undefined
// Stockfish in the empty second seat, at an Elo of 1320 to 3190.
addBot: { elo: number }
}

export type ChessMoveName = keyof ChessMovePayloads
2 changes: 2 additions & 0 deletions src/apps/topology/topology.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ flowchart LR
mcpserver["mcpserver"]
portrait["portrait"]
mithril["mithril"]
chess_engine["chess_engine"]
posterize["posterize"]
microgpt-serve["microgpt-serve"]
iili["iili"]
Expand Down Expand Up @@ -112,6 +113,7 @@ flowchart LR
games_hub -->|sql| shared_postgres
games_hub -->|http| microgpt-serve
games_hub -->|http| mithril
games_hub -->|http| chess_engine
one_d4 -->|sql| shared_postgres
one_d4_worker -->|sql| shared_postgres
iili -->|sql| shared_postgres
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/__tests__/useChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ describe('useChessTable', () => {
expect(move.mock.calls).toEqual([['startGame', { initialSeconds: 60, incrementSeconds: 1 }]])
})

it('a bot is asked for at its strength', () => {
const { result, move } = mount()
act(() => result.current.addBot(1600))
expect(move.mock.calls).toEqual([['addBot', { elo: 1600 }]])
})

it('a play and a resignation go out as the hub spells them', () => {
const { result, receive, move } = mount()
receive({ gameJoined: { view: view() } })
Expand Down Expand Up @@ -107,6 +113,18 @@ describe('useChessTable', () => {
expect(result.current.opening).toBe(false)
})

it('holds a bot asked for until its seat arrives, or the hub refuses it', () => {
const { result, receive } = mount()
receive({ gameState: { view: view({ phase: 'waiting' }) } })
act(() => result.current.addBot(1600))
expect(result.current.seating).toBe(true)
receive({ gameState: { view: view({ phase: 'waiting' }) } })
expect(result.current.seating).toBe(false)
act(() => result.current.addBot(1600))
act(() => result.current.handleRejected())
expect(result.current.seating).toBe(false)
})

it('announces another seat’s table, never its own', () => {
const { receive, showNotice } = mount()
receive({ gameCreated: { gameId: 'GAME01', createdBy: 'alice' } })
Expand Down
30 changes: 23 additions & 7 deletions src/hooks/useChessTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface ChessTableActions {
playAgain: () => void
play: (uci: string) => void
resign: () => void
// Stockfish in the second seat, at an Elo of 1320 to 3190.
addBot: (elo: number) => void
}

export interface UseChessTable extends ChessTableActions {
Expand All @@ -24,6 +26,8 @@ export interface UseChessTable extends ChessTableActions {
view: ChessView | null
// Another game has been asked for and not yet arrived.
opening: boolean
// A bot has been asked for and its seat not yet arrived.
seating: boolean
handleUpdate: (update: ChessUpdate) => void
handleRejected: () => void
clear: () => void
Expand All @@ -39,23 +43,28 @@ export interface UseChessTableProps {
export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTableProps): UseChessTable => {
const [view, setView] = useState<ChessView | null>(null)
const [opening, setOpening] = useState(false)
const [seating, setSeating] = useState(false)

const clear = useCallback(() => {
setView(null)
// Held until the hub answers with a view, or refuses.
const settle = useCallback(() => {
setOpening(false)
setSeating(false)
}, [])
const handleRejected = useCallback(() => setOpening(false), [])
const clear = useCallback(() => {
setView(null)
settle()
}, [settle])

const handleUpdate = useCallback(
(update: ChessUpdate) => {
if (update.gameJoined) {
setView(update.gameJoined.view)
setOpening(false)
settle()
return
}
if (update.gameState) {
setView(update.gameState.view)
setOpening(false)
settle()
return
}
if (update.gameCreated) {
Expand All @@ -68,7 +77,7 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa
}
// gameStarted, turnChanged and gameEnded: the view says it all.
},
[clear, onLeft, playerId, showNotice]
[clear, onLeft, playerId, settle, showNotice]
)

const createTable = useCallback(() => move('createGame'), [move])
Expand All @@ -95,6 +104,13 @@ export const useChessTable = ({ playerId, move, showNotice, onLeft }: UseChessTa
}, [clear, move, onLeft, view])
const play = useCallback((uci: string) => move('play', { uci }), [move])
const resign = useCallback(() => move('resign'), [move])
const addBot = useCallback(
(elo: number) => {
setSeating(true)
move('addBot', { elo })
},
[move]
)

return { view, opening, handleUpdate, handleRejected, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign }
return { view, opening, seating, handleUpdate, handleRejected: settle, clear, createTable, joinTable, startTable, leaveTable, playAgain, play, resign, addBot }
}
Loading