diff --git a/src/apps/go/__tests__/terms.test.ts b/src/apps/go/__tests__/terms.test.ts new file mode 100644 index 0000000..877fed1 --- /dev/null +++ b/src/apps/go/__tests__/terms.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { nameOf } from '../terms' + +describe('nameOf', () => { + // A bot's id ("gnugo@4") names its engine and level, as the hub mints + // it (games_hub go_bots.h); a player's id is their name. + it('names a bot by its engine and level, and a player by their id', () => { + expect(nameOf('gnugo@4')).toBe('GNU Go 4') + expect(nameOf('gnugo@10')).toBe('GNU Go 10') + expect(nameOf('alice')).toBe('alice') + expect(nameOf('gnugo@x')).toBe('gnugo@x') + }) +}) diff --git a/src/apps/go/components/GoTable.tsx b/src/apps/go/components/GoTable.tsx index 4a2411a..1d44960 100644 --- a/src/apps/go/components/GoTable.tsx +++ b/src/apps/go/components/GoTable.tsx @@ -7,7 +7,7 @@ import AboutGame from '@/apps/lobby/components/AboutGame' import { formatClock } from '@/apps/chess/rules' import type { GoBoardSize, GoColor, GoResult, GoView } from '../wire' import { unlockGoSounds } from '../sounds' -import { CLOCKS, SIZES, clockLine } from '../terms' +import { BOT_LEVELS, CLOCKS, SIZES, clockLine, nameOf as seatName } from '../terms' import { useGoSounds } from '../useGoSounds' import styles from './GoTable.module.css' @@ -115,7 +115,7 @@ const colorName = (color: GoColor) => (color === 'black' ? 'Black' : 'White') function describeResult(result: GoResult, playerId: string): string { const scores = result.score === undefined ? '' : ` Black ${result.score.black}, White ${result.score.white}.` - const winner = result.winner === undefined ? 'Nobody' : result.winner === playerId ? 'You' : result.winner + const winner = result.winner === undefined ? 'Nobody' : result.winner === playerId ? 'You' : seatName(result.winner) const color = result.winnerColor === undefined ? '' : ` (${colorName(result.winnerColor)})` const how = { score: 'on points', resignation: 'by resignation', timeout: 'on time', abandoned: 'by abandonment' }[result.ending] return `${winner}${color} won ${how}.${scores}` @@ -268,6 +268,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { : view.terms === undefined ? Object.keys(CLOCKS)[0] : clockLine(view.terms.initialSeconds, view.terms.incrementSeconds) + const [botLevel, setBotLevel] = useState(BOT_LEVELS[1].level) // Held for one view: any move answered puts the question away. const [confirmResign, setConfirmResign] = useState(null) // Held for one phase: the game ending or starting puts them away. @@ -310,7 +311,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { const accepted = view.scoring?.acceptedBy.includes(playerId) ?? false const nameOf = (color: GoColor) => { const seat = view.players.find(player => player.color === color) - return seat === undefined ? colorName(color) : seat.playerId === playerId ? `${seat.playerId} (you)` : seat.playerId + return seat === undefined ? colorName(color) : seat.playerId === playerId ? `${seat.playerId} (you)` : seatName(seat.playerId) } const status = (() => { @@ -336,7 +337,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { if (view.phase === 'closed') return watching ? 'The table closed.' : 'Your opponent left the table.' if (scoring) { const count = view.scoring === undefined ? '' : ` Black ${view.scoring.score.black}, White ${view.scoring.score.white}.` - const accepts = (view.scoring?.acceptedBy ?? []).map(id => (id === playerId ? ' You accept.' : ` ${id} accepts.`)).join('') + const accepts = (view.scoring?.acceptedBy ?? []).map(id => (id === playerId ? ' You accept.' : ` ${seatName(id)} accepts.`)).join('') if (watching) return `Scoring: the players mark dead stones.${count}${accepts}` return `Mark the dead stones, then accept${myTurn ? ', or play on' : ''}.${count}${accepts}` } @@ -350,7 +351,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { : last.x !== undefined && last.y !== undefined ? `${colorName(last.color)} played ${pointName(last.x, last.y, boardSize)}. ` : '' - return myTurn ? `${said}Your move.` : `${said}${view.currentPlayerId ?? ''} to move.` + return myTurn ? `${said}Your move.` : `${said}${seatName(view.currentPlayerId ?? '')} to move.` })() const sizePicker = (label: string) => ( @@ -399,9 +400,24 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { + {/* Alone at the table, a bot can take the other chair. */} + {!watching && !pairing && alone && ( +
+ + +
+ )} {!watching && !pairing && (
{sizePicker('Board size')} diff --git a/src/apps/go/components/__tests__/GoTable.test.tsx b/src/apps/go/components/__tests__/GoTable.test.tsx index 498d050..d337f4e 100644 --- a/src/apps/go/components/__tests__/GoTable.test.tsx +++ b/src/apps/go/components/__tests__/GoTable.test.tsx @@ -31,6 +31,8 @@ const view = (over: Partial = {}): GoView => ({ const table = (over: Partial = {}): GoTableProps['table'] => ({ opening: false, sent: false, + seating: false, + addBot: vi.fn(), startTable: vi.fn(), postChallenge: vi.fn(), leaveTable: vi.fn(), @@ -53,6 +55,9 @@ const scoring = (over: Partial = {}) => ...over }) +const alone = (over: Partial = {}) => + view({ phase: 'waiting', players: [{ playerId: 'alice' }], size: undefined, board: undefined, ...over }) + const mount = (props: Partial = {}) => { const held = props.table ?? table() render() @@ -460,4 +465,60 @@ describe('GoTable', () => { fireEvent.pointerDown(screen.getByTestId('go-status')) expect(unlockGoSounds).toHaveBeenCalledTimes(2) }) + + // Alone at a table, a bot can take the other chair, at a level GNU Go has. + it('a lone seat adds a bot at the level picked', () => { + const held = mount({ view: alone() }) + const levels = screen.getByRole('combobox', { name: 'Bot strength' }) + expect(within(levels).getAllByRole('option').map(option => option.textContent)).toEqual([ + 'Beginner (level 1)', + 'Casual (level 4)', + 'Club (level 7)', + 'Strongest (level 10)' + ]) + fireEvent.change(levels, { target: { value: '7' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add a bot' })) + expect(held.addBot).toHaveBeenCalledWith(7) + }) + + it('a bot is added only to a lone seat’s own ordinary table, once at a time', () => { + mount({ view: alone({ players: [{ playerId: 'alice' }, { playerId: 'bob' }] }) }) + expect(screen.queryByRole('button', { name: 'Add a bot' })).toBeNull() + cleanup() + mount({ view: alone({ roundRobinId: 'E1' }) }) + expect(screen.queryByRole('button', { name: 'Add a bot' })).toBeNull() + cleanup() + mount({ view: alone({ players: [{ playerId: 'bob' }] }) }) + expect(screen.queryByRole('button', { name: 'Add a bot' })).toBeNull() + cleanup() + mount({ view: alone(), table: table({ seating: true }) }) + expect((screen.getByRole('button', { name: 'Add a bot' }) as HTMLButtonElement).disabled).toBe(true) + cleanup() + mount({ view: alone(), connected: false }) + expect((screen.getByRole('button', { name: 'Add a bot' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('a bot is named by its engine and level wherever a seat is named', () => { + mount({ view: alone({ players: [{ playerId: 'alice' }, { playerId: 'gnugo@4', bot: true }] }) }) + expect(screen.getByText('GNU Go 4')).toBeTruthy() + cleanup() + const players = [ + { playerId: 'alice', color: 'black' as const }, + { playerId: 'gnugo@4', color: 'white' as const, bot: true } + ] + mount({ view: view({ players, currentPlayerId: 'gnugo@4', sideToMove: 'white', lastMove: undefined }) }) + expect(screen.getByTestId('go-status').textContent).toBe('GNU Go 4 to move.') + cleanup() + mount({ view: scoring({ players, scoring: { acceptedBy: ['gnugo@4'], score: { black: 81, white: 6.5 } } }) }) + expect(screen.getByTestId('go-status').textContent).toContain(' GNU Go 4 accepts.') + cleanup() + mount({ + view: view({ + phase: 'ended', + players: [{ playerId: 'alice', color: 'black' }, { playerId: 'gnugo@4', color: 'white', bot: true }], + result: { ending: 'resignation', winner: 'gnugo@4', winnerColor: 'white' } + }) + }) + expect(screen.getByTestId('go-status').textContent).toBe('GNU Go 4 (White) won by resignation.') + }) }) diff --git a/src/apps/go/terms.ts b/src/apps/go/terms.ts index 9c53b35..8eaac99 100644 --- a/src/apps/go/terms.ts +++ b/src/apps/go/terms.ts @@ -14,3 +14,19 @@ export const clockLine = (initialSeconds: number, incrementSeconds: number) => `${initialSeconds % 60 === 0 ? initialSeconds / 60 : `${initialSeconds}s`}+${incrementSeconds}` export const termsLine = (terms: GoTerms) => `${terms.size}×${terms.size} · komi ${terms.komi} · ${clockLine(terms.initialSeconds, terms.incrementSeconds)}` + +// A bot's levels, GNU Go's own (1 to 10). +export const BOT_LEVELS: Array<{ level: number; name: string }> = [ + { level: 1, name: 'Beginner' }, + { level: 4, name: 'Casual' }, + { level: 7, name: 'Club' }, + { level: 10, name: 'Strongest' } +] + +// A seat as people read it: a player's id is their name; a bot's +// ("gnugo@4") its engine and level. The id's shape is the hub's bot-naming +// contract (games_hub go_bots.h). +export function nameOf(playerId: string): string { + const bot = /^gnugo@(\d+)$/.exec(playerId) + return bot === null ? playerId : `GNU Go ${bot[1]}` +} diff --git a/src/apps/go/wire.ts b/src/apps/go/wire.ts index 4c88fd1..50a1a34 100644 --- a/src/apps/go/wire.ts +++ b/src/apps/go/wire.ts @@ -18,6 +18,8 @@ export interface GoPlayer { playerId: string // Absent while waiting. color?: GoColor + // A bot's seat (GNU Go), its id naming the engine and level ("gnugo@4"). + bot?: boolean } // Each side's time left as of the moment the hub built the view; the @@ -153,6 +155,8 @@ export interface GoMovePayloads { forfeit: { roundRobinId: string; winner: string; loser: string } roundRobins: undefined playRoundRobin: { roundRobinId: string; opponent: string } + // GNU Go to a lone seat's table not yet started, at a level 1 to 10. + addBot: { level: number } } export type GoMoveName = keyof GoMovePayloads diff --git a/src/hooks/__tests__/useGoTable.test.tsx b/src/hooks/__tests__/useGoTable.test.tsx index 2219fd5..4727dca 100644 --- a/src/hooks/__tests__/useGoTable.test.tsx +++ b/src/hooks/__tests__/useGoTable.test.tsx @@ -181,4 +181,18 @@ describe('useGoTable', () => { expect(hook.result.current.view).not.toBeNull() expect(onLeft).not.toHaveBeenCalled() }) + + // A bot seated is held as a seat taken: the next view, or a refusal, + // lets it go. + it('adds a bot at its level, held until the hub answers', () => { + const { hook, move } = setup() + act(() => hook.result.current.addBot(4)) + expect(move).toHaveBeenCalledWith('addBot', { level: 4 }) + expect(hook.result.current.seating).toBe(true) + act(() => hook.result.current.handleUpdate({ gameState: { view: view({ phase: 'waiting' }) } })) + expect(hook.result.current.seating).toBe(false) + act(() => hook.result.current.addBot(4)) + act(() => hook.result.current.handleRejected()) + expect(hook.result.current.seating).toBe(false) + }) }) diff --git a/src/hooks/useGoTable.ts b/src/hooks/useGoTable.ts index 967b4ea..4736b23 100644 --- a/src/hooks/useGoTable.ts +++ b/src/hooks/useGoTable.ts @@ -23,6 +23,10 @@ export interface GoTableActions { // A move sent and not yet answered: the next view, or a refusal, lets it go. sent: boolean resign: () => void + // GNU Go to the other chair, at a level; held as `seating` until the hub + // answers. + addBot: (level: number) => void + seating: boolean } export interface UseGoTable extends GoTableActions { @@ -57,12 +61,14 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro const [view, setView] = useState(null) const [opening, setOpening] = useState(false) const [sent, setSent] = useState(false) + const [seating, setSeating] = useState(false) const askList = useCallback(() => move('roundRobins'), [move]) const { roundRobins, load: loadRoundRobins, heardList, heardOne, reset: resetRoundRobins } = useRoundRobinList(askList) const settle = useCallback(() => { setOpening(false) setSent(false) + setSeating(false) }, []) const clearTable = useCallback(() => { setView(null) @@ -173,6 +179,13 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro move('accept') }, [move]) const resign = useCallback(() => move('resign'), [move]) + const addBot = useCallback( + (level: number) => { + setSeating(true) + move('addBot', { level }) + }, + [move] + ) return { view, @@ -193,6 +206,8 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro markDead, accept, resign, + addBot, + seating, roundRobins, loadRoundRobins, createRoundRobin,