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
51 changes: 44 additions & 7 deletions src/apps/go/components/GoTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const CLOCKS: Record<string, { initialSeconds: number; incrementSeconds: number
'20+10': { initialSeconds: 1200, incrementSeconds: 10 }
}

// A clock as the lobby says it: whole minutes in minutes, else seconds.
const clockLine = (initialSeconds: number, incrementSeconds: number) =>
`${initialSeconds % 60 === 0 ? initialSeconds / 60 : `${initialSeconds}s`}+${incrementSeconds}`

// Columns are lettered without I, as go boards are.
const COLUMNS = 'ABCDEFGHJKLMNOPQRST'
const pointName = (x: number, y: number, size: number) => `${COLUMNS[x]}${size - y}`
Expand Down Expand Up @@ -253,8 +257,28 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {
// A size picked holds while the table's size is the one it was picked
// against; otherwise the picker shows the table's.
const [sizeChoice, setSizeChoice] = useState<{ against?: GoBoardSize; size: GoBoardSize } | null>(null)
const size = sizeChoice !== null && sizeChoice.against === view.size ? sizeChoice.size : (view.size ?? 19)
const [clock, setClock] = useState(Object.keys(CLOCKS)[0])
const size = sizeChoice !== null && sizeChoice.against === view.size ? sizeChoice.size : (view.size ?? view.terms?.size ?? 19)
// The offered clocks, and a posted challenge's among them even when it
// is none of those, so updating the challenge never quietly changes it.
const clocks =
view.terms === undefined
? CLOCKS
: {
...CLOCKS,
[clockLine(view.terms.initialSeconds, view.terms.incrementSeconds)]: {
initialSeconds: view.terms.initialSeconds,
incrementSeconds: view.terms.incrementSeconds
}
}
// The posted challenge's clock shows until another is picked; a pick no
// longer offered falls back.
const [clockChoice, setClock] = useState<string | null>(null)
const clock =
clockChoice !== null && clockChoice in clocks
? clockChoice
: view.terms === undefined
? Object.keys(CLOCKS)[0]
: clockLine(view.terms.initialSeconds, view.terms.incrementSeconds)
// 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 @@ -282,6 +306,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {
}, [view.phase])
const me = view.players.find(player => player.playerId === playerId)
const watching = me === undefined
const alone = view.players.length < 2
const mine: GoColor = me?.color ?? 'black'
const theirs: GoColor = mine === 'black' ? 'white' : 'black'
// A cursor off a smaller board comes back to its center.
Expand All @@ -298,8 +323,13 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {

const status = (() => {
if (view.phase === 'waiting') {
const terms = view.terms
if (terms !== undefined) {
const posted = `Challenge posted: ${terms.size}×${terms.size}, komi ${terms.komi}, ${clockLine(terms.initialSeconds, terms.incrementSeconds)}.`
return watching || view.players.length > 1 ? posted : `${posted} Whoever joins starts on it.`
}
if (watching) return 'Waiting for the players to start.'
return view.players.length < 2 ? 'Waiting for a second seat.' : 'Pick a board and clock, then start.'
return view.players.length < 2 ? 'Post a challenge, or wait for a second seat.' : 'Pick a board and clock, then start.'
}
if (view.phase === 'ended' || view.result !== undefined) return view.result === undefined ? '' : describeResult(view.result, playerId)
if (view.phase === 'closed') return watching ? 'The table closed.' : 'Your opponent left the table.'
Expand Down Expand Up @@ -371,18 +401,25 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {
<li key={player.playerId}>{player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}</li>
))}
</ul>
{view.players.length === 2 && !watching && (
{!watching && (
<div className={styles.actions}>
{sizePicker('Board size')}
<select aria-label="Clock" value={clock} onChange={event => setClock(event.target.value)}>
{Object.keys(CLOCKS).map(name => (
{Object.keys(clocks).map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<button type="button" className={felt.primary} onClick={() => table.startTable({ size, ...CLOCKS[clock] })} disabled={!connected}>
Start
{/* Alone, the choice is posted as a challenge that whoever
joins starts on; with two seats, it starts the game. */}
<button
type="button"
className={felt.primary}
onClick={() => (alone ? table.postChallenge : table.startTable)({ size, ...clocks[clock] })}
disabled={!connected}
>
{!alone ? 'Start' : view.terms === undefined ? 'Post challenge' : 'Update challenge'}
</button>
</div>
)}
Expand Down
45 changes: 45 additions & 0 deletions src/apps/go/components/__tests__/GoTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const table = (over: Partial<GoTableProps['table']> = {}): GoTableProps['table']
opening: false,
sent: false,
startTable: vi.fn(),
postChallenge: vi.fn(),
leaveTable: vi.fn(),
playAgain: vi.fn(),
play: vi.fn(),
Expand Down Expand Up @@ -164,6 +165,50 @@ describe('GoTable', () => {
expect(held.startTable).toHaveBeenCalledWith({ size: 13, initialSeconds: 600, incrementSeconds: 5 })
})

it('alone, the picked board size and clock are posted as a challenge', () => {
const held = mount({ view: view({ phase: 'waiting', players: [{ playerId: 'alice' }], size: undefined, board: undefined }) })
expect(screen.getByTestId('go-status').textContent).toBe('Post a challenge, or wait for a second seat.')
fireEvent.change(screen.getByRole('combobox', { name: 'Board size' }), { target: { value: '13' } })
fireEvent.change(screen.getByRole('combobox', { name: 'Clock' }), { target: { value: '5+3' } })
fireEvent.click(screen.getByRole('button', { name: 'Post challenge' }))
expect(held.postChallenge).toHaveBeenCalledWith({ size: 13, initialSeconds: 300, incrementSeconds: 3 })
expect(held.startTable).not.toHaveBeenCalled()
})

it('a posted challenge is said, picked again, and updated in place', () => {
const terms = { size: 13 as const, komi: 6.5, initialSeconds: 300, incrementSeconds: 3 }
const held = mount({ view: view({ phase: 'waiting', players: [{ playerId: 'alice' }], size: undefined, board: undefined, terms }) })
expect(screen.getByTestId('go-status').textContent).toBe('Challenge posted: 13×13, komi 6.5, 5+3. Whoever joins starts on it.')
expect((screen.getByRole('combobox', { name: 'Board size' }) as HTMLSelectElement).value).toBe('13')
expect((screen.getByRole('combobox', { name: 'Clock' }) as HTMLSelectElement).value).toBe('5+3')
fireEvent.change(screen.getByRole('combobox', { name: 'Board size' }), { target: { value: '9' } })
fireEvent.click(screen.getByRole('button', { name: 'Update challenge' }))
expect(held.postChallenge).toHaveBeenCalledWith({ size: 9, initialSeconds: 300, incrementSeconds: 3 })
})

it('a watcher of a challenge is told its terms and posts nothing', () => {
const terms = { size: 9 as const, komi: 6.5, initialSeconds: 600, incrementSeconds: 5 }
mount({ playerId: 'carol', view: view({ phase: 'waiting', players: [{ playerId: 'alice' }], size: undefined, board: undefined, terms }) })
expect(screen.getByTestId('go-status').textContent).toBe('Challenge posted: 9×9, komi 6.5, 10+5.')
expect(screen.queryByRole('button', { name: /challenge/ })).toBeNull()
})

it('a posted clock none of the presets has is offered, and updating keeps it', () => {
const terms = { size: 9 as const, komi: 6.5, initialSeconds: 60, incrementSeconds: 0 }
const held = mount({ view: view({ phase: 'waiting', players: [{ playerId: 'alice' }], size: undefined, board: undefined, terms }) })
expect((screen.getByRole('combobox', { name: 'Clock' }) as HTMLSelectElement).value).toBe('1+0')
fireEvent.click(screen.getByRole('button', { name: 'Update challenge' }))
expect(held.postChallenge).toHaveBeenCalledWith({ size: 9, initialSeconds: 60, incrementSeconds: 0 })
})

it('with both seats on terms whose start failed, the terms are said and Start plays them', () => {
const terms = { size: 13 as const, komi: 6.5, initialSeconds: 300, incrementSeconds: 3 }
const held = mount({ view: view({ phase: 'waiting', players: [{ playerId: 'alice' }, { playerId: 'bob' }], size: undefined, board: undefined, terms }) })
expect(screen.getByTestId('go-status').textContent).toBe('Challenge posted: 13×13, komi 6.5, 5+3.')
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
expect(held.startTable).toHaveBeenCalledWith({ size: 13, initialSeconds: 300, incrementSeconds: 3 })
})

it('says the result, and offers the next game on the size picked', () => {
const result = { ending: 'score' as const, winner: 'bob', winnerColor: 'white' as const, score: { black: 40, white: 45.5 } }
const held = mount({ view: view({ phase: 'ended', result }) })
Expand Down
13 changes: 13 additions & 0 deletions src/apps/go/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,20 @@ export interface GoScoreLine {
ending: GoEnding
}

// A challenge's terms, the hub's defaults filled in.
export interface GoTerms {
size: GoBoardSize
komi: number
initialSeconds: number
incrementSeconds: number
}

export interface GoView {
gameId: string
phase: GoPhase
players: GoPlayer[]
// The posted challenge, while the table waits on it.
terms?: GoTerms
// Absent while waiting.
size?: GoBoardSize
// One string per row from the top, one character per point: '.', 'b' or 'w'.
Expand Down Expand Up @@ -115,6 +125,9 @@ export interface GoMovePayloads {
// again. Withdraws both acceptances.
markDead: { x: number; y: number }
accept: undefined
// A lone seat's terms for whoever joins to start on; posting again
// replaces them.
challenge: GoMovePayloads['startGame']
}

export type GoMoveName = keyof GoMovePayloads
6 changes: 6 additions & 0 deletions src/hooks/__tests__/useGoTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ describe('useGoTable', () => {
expect(onLeft).toHaveBeenCalledTimes(1)
})

it('posts a challenge’s terms for whoever joins to start on', () => {
const { hook, move } = setup()
act(() => hook.result.current.postChallenge({ size: 13, initialSeconds: 300, incrementSeconds: 3 }))
expect(move).toHaveBeenCalledWith('challenge', { size: 13, initialSeconds: 300, incrementSeconds: 3 })
})

it('watches a table by its id', () => {
const { hook, move } = setup()
act(() => hook.result.current.watchTable('G07'))
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/useGoTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { GoBoardSize, GoMoveName, GoMovePayloads, GoUpdate, GoView } from '
export interface GoTableActions {
// Board size, komi and clock; absent fields use the hub's defaults.
startTable: (terms?: GoMovePayloads['startGame']) => void
// Alone at the table: the terms whoever joins starts on.
postChallenge: (terms: GoMovePayloads['challenge']) => void
leaveTable: () => void
// Another game: the table's next, on its clock and komi, while it is
// open; a new table once it has closed.
Expand Down Expand Up @@ -87,6 +89,7 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro
)
const watchTable = useCallback((gameId: string) => move('watch', { gameId }), [move])
const startTable = useCallback((terms: GoMovePayloads['startGame'] = {}) => move('startGame', terms), [move])
const postChallenge = useCallback((terms: GoMovePayloads['challenge']) => move('challenge', terms), [move])
const playAgain = useCallback(
(size?: GoBoardSize) => {
setOpening(true)
Expand Down Expand Up @@ -147,6 +150,7 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro
joinTable,
watchTable,
startTable,
postChallenge,
leaveTable,
playAgain,
play,
Expand Down
Loading