From 03e2b13d3542693394b0f88e1ad6e745479da0db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:08:49 +0000 Subject: [PATCH 1/2] Go: a lone seat posts a challenge Refs muchq/MoonBase#1668. --- src/apps/go/components/GoTable.tsx | 33 +++++++++++++++---- .../go/components/__tests__/GoTable.test.tsx | 29 ++++++++++++++++ src/apps/go/wire.ts | 13 ++++++++ src/hooks/__tests__/useGoTable.test.tsx | 6 ++++ src/hooks/useGoTable.ts | 4 +++ 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/apps/go/components/GoTable.tsx b/src/apps/go/components/GoTable.tsx index c886e0e..5d6fcab 100644 --- a/src/apps/go/components/GoTable.tsx +++ b/src/apps/go/components/GoTable.tsx @@ -33,6 +33,10 @@ const CLOCKS: Record + `${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}` @@ -253,8 +257,13 @@ 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) + // A posted challenge's clock shows until another is picked. + const [clockChoice, setClock] = useState(null) + const postedClock = Object.keys(CLOCKS).find( + name => CLOCKS[name].initialSeconds === view.terms?.initialSeconds && CLOCKS[name].incrementSeconds === view.terms?.incrementSeconds + ) + const clock = clockChoice ?? postedClock ?? Object.keys(CLOCKS)[0] // 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. @@ -282,6 +291,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. @@ -298,8 +308,12 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { const status = (() => { if (view.phase === 'waiting') { + const terms = view.terms + if (terms !== undefined) { + return `Challenge posted: ${terms.size}×${terms.size}, komi ${terms.komi}, ${clockLine(terms.initialSeconds, terms.incrementSeconds)}. 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.' @@ -371,7 +385,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {
  • {player.playerId === playerId ? `${player.playerId} (you)` : player.playerId}
  • ))} - {view.players.length === 2 && !watching && ( + {!watching && (
    {sizePicker('Board size')} -
    )} diff --git a/src/apps/go/components/__tests__/GoTable.test.tsx b/src/apps/go/components/__tests__/GoTable.test.tsx index 329c6cb..9bb154f 100644 --- a/src/apps/go/components/__tests__/GoTable.test.tsx +++ b/src/apps/go/components/__tests__/GoTable.test.tsx @@ -32,6 +32,7 @@ const table = (over: Partial = {}): GoTableProps['table'] opening: false, sent: false, startTable: vi.fn(), + postChallenge: vi.fn(), leaveTable: vi.fn(), playAgain: vi.fn(), play: vi.fn(), @@ -164,6 +165,34 @@ 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. Whoever joins starts on it.') + expect(screen.queryByRole('button', { name: /challenge/ })).toBeNull() + }) + 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 }) }) diff --git a/src/apps/go/wire.ts b/src/apps/go/wire.ts index 9ce39c3..eb7a376 100644 --- a/src/apps/go/wire.ts +++ b/src/apps/go/wire.ts @@ -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'. @@ -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 diff --git a/src/hooks/__tests__/useGoTable.test.tsx b/src/hooks/__tests__/useGoTable.test.tsx index 0ef32b5..9202a66 100644 --- a/src/hooks/__tests__/useGoTable.test.tsx +++ b/src/hooks/__tests__/useGoTable.test.tsx @@ -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')) diff --git a/src/hooks/useGoTable.ts b/src/hooks/useGoTable.ts index 0a9b006..4db641b 100644 --- a/src/hooks/useGoTable.ts +++ b/src/hooks/useGoTable.ts @@ -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. @@ -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) @@ -147,6 +150,7 @@ export const useGoTable = ({ playerId, move, showNotice, onLeft }: UseGoTablePro joinTable, watchTable, startTable, + postChallenge, leaveTable, playAgain, play, From 744d811606054d41ba2bf0430835aa34a102efc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:41:58 +0000 Subject: [PATCH 2/2] Go: a challenge's own clock is offered; two seats aren't told to wait --- src/apps/go/components/GoTable.tsx | 32 ++++++++++++++----- .../go/components/__tests__/GoTable.test.tsx | 18 ++++++++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/apps/go/components/GoTable.tsx b/src/apps/go/components/GoTable.tsx index 5d6fcab..c197c6a 100644 --- a/src/apps/go/components/GoTable.tsx +++ b/src/apps/go/components/GoTable.tsx @@ -258,12 +258,27 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { // 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 ?? view.terms?.size ?? 19) - // A posted challenge's clock shows until another is picked. + // 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(null) - const postedClock = Object.keys(CLOCKS).find( - name => CLOCKS[name].initialSeconds === view.terms?.initialSeconds && CLOCKS[name].incrementSeconds === view.terms?.incrementSeconds - ) - const clock = clockChoice ?? postedClock ?? Object.keys(CLOCKS)[0] + 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(null) // Held for one phase: the game ending or starting puts them away. @@ -310,7 +325,8 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => { if (view.phase === 'waiting') { const terms = view.terms if (terms !== undefined) { - return `Challenge posted: ${terms.size}×${terms.size}, komi ${terms.komi}, ${clockLine(terms.initialSeconds, terms.incrementSeconds)}. Whoever joins starts on it.` + 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 ? 'Post a challenge, or wait for a second seat.' : 'Pick a board and clock, then start.' @@ -389,7 +405,7 @@ const GoTable = ({ playerId, connected, view, table }: GoTableProps) => {
    {sizePicker('Board size')}