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
13 changes: 13 additions & 0 deletions src/apps/go/__tests__/terms.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
28 changes: 22 additions & 6 deletions src/apps/go/components/GoTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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<GoView | null>(null)
// Held for one phase: the game ending or starting puts them away.
Expand Down Expand Up @@ -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 = (() => {
Expand All @@ -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}`
}
Expand All @@ -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) => (
Expand Down Expand Up @@ -399,9 +400,24 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {
<AboutGame game="go" className={felt.hint} />
<ul>
{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)` : seatName(player.playerId)}</li>
))}
</ul>
{/* Alone at the table, a bot can take the other chair. */}
{!watching && !pairing && alone && (
<div className={styles.actions}>
<select aria-label="Bot strength" value={botLevel} onChange={event => setBotLevel(Number(event.target.value))}>
{BOT_LEVELS.map(({ level, name }) => (
<option key={level} value={level}>
{name} (level {level})
</option>
))}
</select>
<button type="button" className={felt.secondary} onClick={() => table.addBot(botLevel)} disabled={!connected || table.seating}>
Add a bot
</button>
</div>
)}
{!watching && !pairing && (
<div className={styles.actions}>
{sizePicker('Board size')}
Expand Down
61 changes: 61 additions & 0 deletions src/apps/go/components/__tests__/GoTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const view = (over: Partial<GoView> = {}): GoView => ({
const table = (over: Partial<GoTableProps['table']> = {}): GoTableProps['table'] => ({
opening: false,
sent: false,
seating: false,
addBot: vi.fn(),
startTable: vi.fn(),
postChallenge: vi.fn(),
leaveTable: vi.fn(),
Expand All @@ -53,6 +55,9 @@ const scoring = (over: Partial<GoView> = {}) =>
...over
})

const alone = (over: Partial<GoView> = {}) =>
view({ phase: 'waiting', players: [{ playerId: 'alice' }], size: undefined, board: undefined, ...over })

const mount = (props: Partial<GoTableProps> = {}) => {
const held = props.table ?? table()
render(<GoTable playerId="alice" connected view={view()} {...props} table={held} />)
Expand Down Expand Up @@ -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鈥檚 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.')
})
})
16 changes: 16 additions & 0 deletions src/apps/go/terms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`
}
4 changes: 4 additions & 0 deletions src/apps/go/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions src/hooks/__tests__/useGoTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
15 changes: 15 additions & 0 deletions src/hooks/useGoTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,12 +61,14 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro
const [view, setView] = useState<GoView | null>(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<GoRoundRobin>(askList)

const settle = useCallback(() => {
setOpening(false)
setSent(false)
setSeating(false)
}, [])
const clearTable = useCallback(() => {
setView(null)
Expand Down Expand Up @@ -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,
Expand All @@ -193,6 +206,8 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro
markDead,
accept,
resign,
addBot,
seating,
roundRobins,
loadRoundRobins,
createRoundRobin,
Expand Down
Loading