diff --git a/src/apps/castle/components/CastleTable.module.css b/src/apps/castle/components/CastleTable.module.css index d1e3c858..458b4bf3 100644 --- a/src/apps/castle/components/CastleTable.module.css +++ b/src/apps/castle/components/CastleTable.module.css @@ -675,13 +675,14 @@ down and to the right so the back's corner shows under it. */ /* Small or short screens: smaller cards, and the other seats fold their fan away for the count — except at the showdown, which is every hand - face up and nothing to fold. */ + face up and nothing to fold: a game's end, or rummy between deals + (data-showdown). */ @media (max-width: 600px), (max-height: 900px) { - .table:not([data-phase='ended']) .seat:not(.mine) .hand { + .table:not([data-phase='ended']):not([data-showdown]) .seat:not(.mine) .hand { display: none; } - .table:not([data-phase='ended']) .seat:not(.mine) .handCount { + .table:not([data-phase='ended']):not([data-showdown]) .seat:not(.mine) .handCount { display: inline; } @@ -939,6 +940,9 @@ down and to the right so the back's corner shows under it. */ padding: 1.5rem; border-radius: 1rem; background: rgba(12, 8, 32, 0.96); + /* Portaled to the body, so it inherits the page's text colour, not the + felt's: set its own for the dark card. */ + color: #fff; border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); text-align: center; diff --git a/src/apps/lobby/components/LobbyGame.tsx b/src/apps/lobby/components/LobbyGame.tsx index 02a54e5b..91d393c6 100644 --- a/src/apps/lobby/components/LobbyGame.tsx +++ b/src/apps/lobby/components/LobbyGame.tsx @@ -31,6 +31,8 @@ const LobbyGame = (props: UseLobbyProps) => { const lobby = useLobby(props) const { castle, golf, rummy, chat, connected, playerId, notice, room } = lobby const atTable = castle.view !== null || golf.view !== null || rummy.view !== null + // A rummy dealer among these lets any seat deal (MoonBase#1609). + const away = (room?.players ?? []).filter(player => !player.connected).map(player => player.playerId) // A table takes the screen, so the panel folds away when one comes up // and returns to what the player wants when it goes. const [panelOpen, setPanelOpen] = useState(panelWanted) @@ -103,7 +105,7 @@ const LobbyGame = (props: UseLobbyProps) => { )} {rummy.view !== null && (
- +
)} {golf.view !== null && ( diff --git a/src/apps/lobby/components/LobbyPanel.tsx b/src/apps/lobby/components/LobbyPanel.tsx index 20234043..0662a887 100644 --- a/src/apps/lobby/components/LobbyPanel.tsx +++ b/src/apps/lobby/components/LobbyPanel.tsx @@ -152,7 +152,7 @@ const LobbyPanel = ({ lobby, roomCodeRef }: LobbyPanelProps) => { return (
  • - {game} {table.gameId} · {table.playerCount}/{TABLE_SEATS} · {table.status} + {game} {table.gameId} · {table.playerCount}/{TABLE_SEATS} · {table.status === 'choosing' ? 'between deals' : table.status} + )) + const actions = (() => { if (view.phase === 'waiting') { return ( ) } + if (between && mayDeal && !showDealEnd) return dealButtons() if (drawing) { return ( <> @@ -243,10 +304,11 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr

    {label} {onTurn && · {view.stage === 'draw' ? 'to draw' : 'to play'}} - {!mine && ( + {!mine && dealt && !handsShown && ( SHOWN_BACKS ? felt.handCountShown : ''}`}> · {seat.handCount} in hand )} - {view.phase === 'ended' && · {deadwood(seat.hand)} pts left} + {dealt && · {wonBy(seat.playerId)} won} + {handsShown && · {deadwood(seat.hand)} pts left}

    @@ -292,7 +354,7 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr
    - {mine && view.phase !== 'waiting' && ( + {mine && dealt && (
    {deadwood(myHand)} pts in hand @@ -312,11 +374,16 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr } return ( -
    +

    Rummy · {view.gameId}

    + {dealt && view.variant !== undefined && ( +

    + Deal {view.dealNumber} · {variantLabel(view.variant)} +

    + )} {status} {view.phase !== 'ended' && (

    - {view.phase === 'ended' && ended !== null ? describeEnding(ended, playerId) : ''} + {view.phase === 'ended' && ended !== null + ? describeTableEnd(ended, playerId) + : handsShown && lastDeal !== undefined && !showDealEnd + ? describeEnding(lastDeal, playerId) + : ''}

    - {showEnding && - ended !== null && + {(showDealEnd || showEnding) && createPortal(
    { if (event.key === 'Escape') { event.preventDefault() @@ -344,43 +415,79 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr keepFocusIn(event) }} > -
    -
    {ended.winner === playerId ? '🏆' : ended.winner === undefined ? '🤝' : '🃏'}
    -

    - {headlineOf(ended, playerId)} -

    -

    {describeEnding(ended, playerId)}

    - {ended.scores.length > 0 && ( - - - - {ended.scores.map(score => ( - - - - - ))} - -
    Points left in hand
    {score.playerId === playerId ? 'You' : score.playerId}{score.playerId === ended.winner ? `wins ${ended.points} pts` : `${score.deadwood} pts left`}
    - )} -
    - -
    - -
    + ) : ( + ended !== null && ( +
    +
    🃏
    +

    + The table closed +

    +

    + {describeTableEnd(ended, playerId)} +

    + {ended.standings.length > 0 && ( + + + + {ended.standings.map(standing => ( + + + + + ))} + +
    Hands won
    {standing.playerId === playerId ? 'You' : standing.playerId}{standing.handsWon} won
    + )} +
    + + +
    + +
    + ) + )}
    , document.body )}
    {fromViewer(view.players, playerId).map((seat, i, all) => renderSeat(seat, clockOf(all.length, i)))} - {(view.phase === 'playing' || view.phase === 'ended') && ( + {view.phase !== 'waiting' && dealt && (
    diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 72bb8e41..2b9f1c4c 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -20,6 +20,12 @@ const myHand = ['K♦', '7♥', '7♣', '8♥', '9♥', '2♠'] const view = (over: Partial = {}): RummyView => ({ gameId: 'M1', phase: 'playing', + variant: 'basic', + dealNumber: 1, + standings: [ + { playerId: 'alice', handsWon: 0 }, + { playerId: 'bob', handsWon: 0 } + ], players: [seat('alice', myHand), seat('bob')], currentPlayerId: 'alice', stage: 'play', @@ -34,12 +40,29 @@ const view = (over: Partial = {}): RummyView => ({ ...over }) +// Between deals: the last deal's cards still on the felt, every hand +// face up, and the next dealer choosing. +const between = ({ dealer, dealNumber = 1, lastDeal }: { dealer: string; dealNumber?: number; lastDeal?: RummyView['lastDeal'] }): RummyView => + view({ + phase: 'choosing', + dealNumber, + currentPlayerId: undefined, + stage: undefined, + canDrawStock: false, + players: dealNumber === 0 ? [seat('alice', []), seat('bob', [])] : [seat('alice', myHand), seat('bob', ['A♠'])], + melds: dealNumber === 0 ? [] : view().melds, + choosing: { dealer, options: ['basic'] }, + lastDeal + }) + const table = (over: Partial = {}): RummyTableProps['table'] => ({ ended: null, selected: [], order: 'suit', opening: false, + dealing: false, startTable: vi.fn(), + chooseVariant: vi.fn(), leaveTable: vi.fn(), playAgain: vi.fn(), drawStock: vi.fn(), @@ -52,9 +75,9 @@ const table = (over: Partial = {}): RummyTableProps['t ...over }) -const mountWith = (v: RummyView, over: Partial = {}, connected = true) => { +const mountWith = (v: RummyView, over: Partial = {}, connected = true, away: string[] = []) => { const t = table(over) - const rendered = render() + const rendered = render() return { ...rendered, t } } @@ -63,13 +86,15 @@ const myHandGroup = () => within(screen.getByRole('group', { name: 'Your hand' } describe('RummyTable', () => { beforeEach(() => cleanup()) - it('deals only once a second seat is in', () => { - const solo = mountWith(view({ phase: 'waiting', players: [seat('alice', [])], currentPlayerId: undefined, stage: undefined })) + it('starts only once a second seat is in', () => { + const solo = mountWith(view({ phase: 'waiting', dealNumber: 0, players: [seat('alice', [])], currentPlayerId: undefined, stage: undefined })) expect(screen.getByText('Waiting for a second seat.')).toBeDefined() - expect(screen.getByRole('button', { name: 'Deal' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Start table' })).toHaveProperty('disabled', true) cleanup() - const pair = mountWith(view({ phase: 'waiting', players: [seat('alice', []), seat('bob', [])], currentPlayerId: undefined, stage: undefined })) - fireEvent.click(screen.getByRole('button', { name: 'Deal' })) + const pair = mountWith( + view({ phase: 'waiting', dealNumber: 0, players: [seat('alice', []), seat('bob', [])], currentPlayerId: undefined, stage: undefined }) + ) + fireEvent.click(screen.getByRole('button', { name: 'Start table' })) expect(pair.t.startTable).toHaveBeenCalledTimes(1) expect(solo.t.startTable).not.toHaveBeenCalled() }) @@ -239,27 +264,149 @@ describe('RummyTable', () => { expect(screen.getByText('bob took 3♥')).toBeDefined() }) - it('the ending names the winner and what everyone held, then gets out of the way', () => { - const ended = { winner: 'bob', points: 43, scores: [{ playerId: 'alice', deadwood: 43 }, { playerId: 'bob', deadwood: 0 }] } - const { t } = mountWith( - view({ phase: 'ended', currentPlayerId: undefined, stage: undefined, players: [seat('alice', myHand), seat('bob', [])] }), - { ended } - ) + it('the deal’s end names the winner and what everyone held; the dealer deals on from it', () => { + const lastDeal = { variant: 'basic', winner: 'bob', points: 43, scores: [{ playerId: 'alice', deadwood: 43 }, { playerId: 'bob', deadwood: 0 }] } + const { t } = mountWith(between({ lastDeal, dealer: 'alice' })) const dialog = within(screen.getByRole('dialog')) - expect(dialog.getByRole('heading', { name: 'bob wins' })).toBeDefined() + expect(dialog.getByRole('heading', { name: 'bob wins the hand' })).toBeDefined() expect(dialog.getByText('bob went out and scores 43 points.')).toBeDefined() expect(dialog.getByRole('row', { name: 'You 43 pts left' })).toBeDefined() expect(dialog.getByRole('row', { name: 'bob wins 43 pts' })).toBeDefined() + expect(dialog.getByText('Your deal next.')).toBeDefined() + fireEvent.click(dialog.getByRole('button', { name: 'Deal Basic rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('basic') + fireEvent.click(dialog.getByRole('button', { name: 'See the hands' })) + expect(screen.queryByRole('dialog')).toBeNull() + // The deal stays under the hand once the dialog is gone, and the last + // deal's cards stay on the felt to be read. + expect(screen.getByRole('button', { name: 'Deal Basic rummy' })).toBeDefined() + expect(screen.getByRole('group', { name: 'melds' })).toBeDefined() + expect(screen.getByText('bob went out and scores 43 points.')).toBeDefined() + }) + + it('at another seat, the deal’s end says who deals next and offers no deal', () => { + const lastDeal = { variant: 'basic', winner: 'alice', points: 12, scores: [] } + mountWith(between({ lastDeal, dealer: 'bob' })) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.getByRole('heading', { name: 'You won the hand!' })).toBeDefined() + expect(dialog.getByText('bob deals next.')).toBeDefined() + expect(dialog.queryByRole('button', { name: /^Deal/ })).toBeNull() + fireEvent.click(dialog.getByRole('button', { name: 'See the hands' })) + expect(screen.getByText('Waiting for bob to deal.')).toBeDefined() + }) + + it('a deal broken up by a leave names nobody', () => { + mountWith(between({ lastDeal: { variant: 'basic', points: 0, scores: [] }, dealer: 'alice' })) + expect(within(screen.getByRole('dialog')).getByRole('heading', { name: 'The deal broke up' })).toBeDefined() + }) + + it('before the first deal, the dealer picks the game and nobody else can', () => { + const { t } = mountWith(between({ dealer: 'alice', dealNumber: 0 })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByText('Your deal: pick the game.')).toBeDefined() + expect(screen.queryByRole('group', { name: 'melds' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Deal Basic rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('basic') + cleanup() + mountWith(between({ dealer: 'bob', dealNumber: 0 })) + expect(screen.getByText('Waiting for bob to deal.')).toBeDefined() + expect(screen.queryByRole('button', { name: /^Deal/ })).toBeNull() + }) + + it('before any deal, a seat is a name: no card counts, no points, no sorting', () => { + mountWith(between({ dealer: 'alice', dealNumber: 0 })) + expect(screen.queryByText(/in hand/)).toBeNull() + expect(screen.queryByRole('group', { name: 'Sort your hand' })).toBeNull() + expect(screen.queryByText(/won/)).toBeNull() + }) + + it('a deal asked for is not asked twice', () => { + mountWith(between({ dealer: 'alice', dealNumber: 0 }), { dealing: true }) + expect(screen.getByRole('button', { name: 'Dealing…' })).toHaveProperty('disabled', true) + }) + + it('between deals every hand is face up, so the felt marks the showdown', () => { + const { container } = mountWith(between({ dealer: 'bob', lastDeal: { variant: 'basic', winner: 'bob', points: 3, scores: [] } })) + expect(container.querySelector('[data-showdown]')).not.toBeNull() + cleanup() + const live = mountWith(view()) + expect(live.container.querySelector('[data-showdown]')).toBeNull() + }) + + it('the deal’s result is said once, by the sheet, and describes it', () => { + const lastDeal = { variant: 'basic', winner: 'bob', points: 3, scores: [] } + mountWith(between({ dealer: 'bob', lastDeal })) + const dialog = screen.getByRole('dialog') + expect(screen.getAllByText('bob went out and scores 3 points.')).toHaveLength(1) + const described = (dialog.getAttribute('aria-describedby') ?? '').split(' ').map(id => document.getElementById(id)?.textContent) + expect(described).toEqual(['bob went out and scores 3 points.', 'bob deals next.']) + }) + + it('the next deal arriving puts focus on the hand, from the sheet or from under it', () => { + const lastDeal = { variant: 'basic', winner: 'bob', points: 3, scores: [] } + const { rerender, t } = mountWith(between({ dealer: 'bob', lastDeal })) + expect(document.activeElement?.textContent).toBe('See the hands') + rerender() + expect(document.activeElement).toBe(screen.getByRole('group', { name: 'Your hand' })) + }) + + it('the dealer going away while the sheet is up puts focus on the deal it now offers', () => { + const lastDeal = { variant: 'basic', winner: 'bob', points: 3, scores: [] } + const v = between({ dealer: 'bob', lastDeal }) + const { rerender, t } = mountWith(v) + expect(document.activeElement?.textContent).toBe('See the hands') + rerender() + expect(document.activeElement?.textContent).toBe('Deal Basic rummy') + }) + + it('face up between deals, a seat shows its cards and points, not a count', () => { + mountWith(between({ dealer: 'alice', lastDeal: { variant: 'basic', winner: 'alice', points: 1, scores: [] } })) + const bob = screen.getByRole('region', { name: /^bob/ }) + expect(bob.textContent).not.toContain('in hand') + expect(bob.textContent).toContain('1 pts left') + }) + + it('a dealer coming back while the sheet is up keeps focus in the sheet', () => { + const lastDeal = { variant: 'basic', winner: 'bob', points: 3, scores: [] } + const v = between({ dealer: 'bob', lastDeal }) + const { rerender, t } = mountWith(v, {}, true, ['bob']) + expect(document.activeElement?.textContent).toBe('Deal Basic rummy') + rerender() + expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(true) + }) + + it('a dealer the room shows away lets anyone deal', () => { + const { t } = mountWith(between({ dealer: 'bob', dealNumber: 0 }), {}, true, ['bob']) + expect(screen.getByText('bob is away: you can deal.')).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: 'Deal Basic rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('basic') + }) + + it('says which deal is on and what it plays, and the hands each seat has won', () => { + mountWith( + view({ + dealNumber: 3, + standings: [ + { playerId: 'alice', handsWon: 2 }, + { playerId: 'bob', handsWon: 0 } + ] + }) + ) + expect(screen.getByText('Deal 3 · Basic rummy')).toBeDefined() + expect(screen.getByRole('region', { name: /^alice \(you\)/ }).textContent).toContain('2 won') + expect(screen.getByRole('region', { name: /^bob/ }).textContent).toContain('0 won') + }) + + it('the table’s end is the hands each seat won, then another table or the room', () => { + const ended = { standings: [{ playerId: 'alice', handsWon: 2 }], dealsPlayed: 3 } + const { t } = mountWith(view({ phase: 'ended', currentPlayerId: undefined, stage: undefined, players: [seat('alice', myHand)] }), { ended }) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.getByRole('heading', { name: 'The table closed' })).toBeDefined() + expect(dialog.getByText('You won 2 of 3 hands.')).toBeDefined() fireEvent.click(dialog.getByRole('button', { name: 'Play again' })) expect(t.playAgain).toHaveBeenCalledTimes(1) fireEvent.click(dialog.getByRole('button', { name: 'See the final hands' })) expect(screen.queryByRole('dialog')).toBeNull() - // The ways on stay under the hand once the dialog is gone. expect(screen.getByRole('button', { name: 'Back to the room' })).toBeDefined() }) - - it('a table that broke up names nobody', () => { - mountWith(view({ phase: 'ended', currentPlayerId: undefined, stage: undefined }), { ended: { points: 0, scores: [] } }) - expect(within(screen.getByRole('dialog')).getByRole('heading', { name: 'The table broke up' })).toBeDefined() - }) }) diff --git a/src/apps/rummy/rules.ts b/src/apps/rummy/rules.ts index 85dc503d..1e6c6b5b 100644 --- a/src/apps/rummy/rules.ts +++ b/src/apps/rummy/rules.ts @@ -4,7 +4,7 @@ // only shape the offer, so they follow its rules to the letter — an offer // the engine refuses is a button that does nothing. -import type { Card, RummyGameEnded, RummyLastMove, RummyMeld, RummyPlayer, RummyView } from './wire' +import type { Card, RummyDealResult, RummyGameEnded, RummyLastMove, RummyMeld, RummyPlayer, RummyView } from './wire' import { face } from '@/apps/castle/rules' export { face, isRed, enteredSince } from '@/apps/castle/rules' @@ -98,17 +98,42 @@ export function describeLastMove(move: RummyLastMove, viewer: string): string { const points = (n: number) => `${n} point${n === 1 ? '' : 's'}` -// How a finished game reads from one chair. -export function describeEnding(ended: RummyGameEnded, viewer: string): string { - if (ended.winner === undefined) return 'Nobody went out.' - return ended.winner === viewer - ? `You went out and score ${points(ended.points)}.` - : `${ended.winner} went out and scores ${points(ended.points)}.` +// How a deal's end reads from one chair. +export function describeEnding(deal: RummyDealResult, viewer: string): string { + if (deal.winner === undefined) return 'Nobody went out.' + return deal.winner === viewer + ? `You went out and score ${points(deal.points)}.` + : `${deal.winner} went out and scores ${points(deal.points)}.` } -export function headlineOf(ended: RummyGameEnded, viewer: string): string { - if (ended.winner === undefined) return 'The table broke up' - return ended.winner === viewer ? 'You won!' : `${ended.winner} wins` +export function headlineOf(deal: RummyDealResult, viewer: string): string { + if (deal.winner === undefined) return 'The deal broke up' + return deal.winner === viewer ? 'You won the hand!' : `${deal.winner} wins the hand` +} + +const hands = (n: number) => `${n} hand${n === 1 ? '' : 's'}` + +// How the table's end reads from one chair: the hands it won, or, to a +// chair that left before the end, what was played. +export function describeTableEnd(ended: RummyGameEnded, viewer: string): string { + const mine = ended.standings.find(standing => standing.playerId === viewer) + if (mine !== undefined) return `You won ${mine.handsWon} of ${hands(ended.dealsPlayed)}.` + return ended.dealsPlayed === 0 ? 'No hands played.' : `${hands(ended.dealsPlayed)} played.` +} + +// Between deals the dealer deals; a dealer the room shows as away lets +// anyone. The hub decides the same way from the same room. +export function canDeal(view: RummyView, viewer: string, away: string[]): boolean { + if (view.phase !== 'choosing' || view.choosing === undefined) return false + return view.choosing.dealer === viewer || away.includes(view.choosing.dealer) +} + +const VARIANT_LABELS: Record = { basic: 'Basic rummy' } + +// A variant as the table names it; one this build does not know yet reads +// as the hub spelled it. +export function variantLabel(variant: string): string { + return VARIANT_LABELS[variant] ?? variant } export function seatOf(view: RummyView, playerId: string): RummyPlayer | undefined { diff --git a/src/apps/rummy/wire.ts b/src/apps/rummy/wire.ts index dfe2c8a0..1e11e2bc 100644 --- a/src/apps/rummy/wire.ts +++ b/src/apps/rummy/wire.ts @@ -2,12 +2,16 @@ // model/rummy.smithy (#245). The view is the UI's model as well as the // wire's: the hub already redacts per viewer, so there is nothing to // translate. +// +// Every table is dealer's choice (MoonBase#1609): started, it waits +// between deals on its dealer's pick; each deal's end passes the deal on; +// it ends only when fewer than two seats are left. import type { Card } from '@/apps/castle/wire' export type { Card } -export type RummyPhase = 'waiting' | 'playing' | 'ended' +export type RummyPhase = 'waiting' | 'choosing' | 'playing' | 'ended' // Where the seat on turn is: about to draw, or holding the drawn card // with melds, lay-offs and the discard to come. @@ -17,7 +21,7 @@ export interface RummyPlayer { playerId: string handCount: number // Faces only for the viewer's own seat, in the order they arrived (a - // drawn card last), and for every seat once the game ends. + // drawn card last), and for every seat once the deal ends. hand: Card[] } @@ -40,9 +44,45 @@ export interface RummyLastMove { meldIndex?: number } +export interface RummyStanding { + playerId: string + handsWon: number +} + +export interface RummyScore { + playerId: string + deadwood: number +} + +// A deal's result: the seat that went out and what it scored (everyone +// else's cards left in hand), or no winner and no points for a deal +// broken up by a leave. +export interface RummyDealResult { + variant: string + winner?: string + points: number + scores: RummyScore[] +} + +// Between deals: who deals next and what they may deal. A dealer the +// room shows as not connected lets any seat deal. +export interface RummyChoosing { + dealer: string + options: string[] +} + export interface RummyView { gameId: string phase: RummyPhase + // The deal in play's variant, or the last one's. + variant?: string + // Deals dealt so far. + dealNumber: number + // Hands won, seat by seat. + standings: RummyStanding[] + choosing?: RummyChoosing + // The last deal's result, between deals and once the table ends. + lastDeal?: RummyDealResult players: RummyPlayer[] currentPlayerId?: string stage?: RummyStage @@ -59,17 +99,11 @@ export interface RummyView { lastMove?: RummyLastMove } -export interface RummyScore { - playerId: string - deadwood: number -} - -// The seat that went out and what it scored (everyone else's cards left -// in hand), or no winner and no points for a table that broke up. +// The table broke up, below two seats: the hands each seat still at it +// won. export interface RummyGameEnded { - winner?: string - points: number - scores: RummyScore[] + standings: RummyStanding[] + dealsPlayed: number } // The rummy update union's JSON encoding: exactly one member present. @@ -90,6 +124,7 @@ export interface RummyMovePayloads { joinGame: { gameId: string } startGame: undefined leaveGame: undefined + chooseVariant: { variant: string } drawStock: undefined drawDiscard: undefined meld: { cards: Card[] } diff --git a/src/hooks/__tests__/useRummyTable.test.tsx b/src/hooks/__tests__/useRummyTable.test.tsx index f2cb37c1..2816aa0d 100644 --- a/src/hooks/__tests__/useRummyTable.test.tsx +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -20,6 +20,12 @@ const seat = (playerId: string, hand: string[] = []): RummyPlayer => ({ const view = (over: Partial = {}): RummyView => ({ gameId: 'GAME01', phase: 'playing', + variant: 'basic', + dealNumber: 1, + standings: [ + { playerId: 'bob', handsWon: 0 }, + { playerId: 'alice', handsWon: 0 } + ], players: [seat('bob'), seat('alice', ['7♥', '8♥', '9♥', 'K♣'])], currentPlayerId: 'alice', stage: 'play', @@ -135,21 +141,37 @@ describe('useRummyTable', () => { expect(mount().result.current.order).toBe('rank') }) - it('a turn is not a toast; the deal and another table opening are', () => { + it('a turn and the table starting are the felt’s to show; another table opening is a toast', () => { const { receive, showNotice } = mount() receive({ turnChanged: { playerId: 'alice' } }) - expect(showNotice).not.toHaveBeenCalled() receive({ gameStarted: {} }) + expect(showNotice).not.toHaveBeenCalled() receive({ gameCreated: { gameId: 'G2', createdBy: 'bob' } }) receive({ gameCreated: { gameId: 'G3', createdBy: 'alice' } }) - expect(showNotice.mock.calls).toEqual([['Dealt. Draw a card to open your turn.'], ['bob opened table G2']]) + expect(showNotice.mock.calls).toEqual([['bob opened table G2']]) + }) + + it('the dealer’s pick names the variant, once, until the hub answers', () => { + const { result, move, receive } = mount() + act(() => result.current.chooseVariant('basic')) + expect(move.mock.calls).toEqual([['chooseVariant', { variant: 'basic' }]]) + expect(result.current.dealing).toBe(true) + receive({ gameState: { view: view() } }) + expect(result.current.dealing).toBe(false) + act(() => result.current.chooseVariant('basic')) + act(() => result.current.handleRejected()) + expect(result.current.dealing).toBe(false) + // A table joined meanwhile answers it too. + act(() => result.current.chooseVariant('basic')) + receive({ gameJoined: { view: view() } }) + expect(result.current.dealing).toBe(false) }) it('play again opens another table; the ending goes with the old one', () => { const { result, receive, move } = mount() receive({ gameState: { view: view({ phase: 'ended' }) } }) - receive({ gameEnded: { winner: 'alice', points: 30, scores: [] } }) - expect(result.current.ended?.winner).toBe('alice') + receive({ gameEnded: { standings: [{ playerId: 'alice', handsWon: 2 }], dealsPlayed: 3 } }) + expect(result.current.ended?.dealsPlayed).toBe(3) act(() => result.current.playAgain()) expect(move.mock.calls).toEqual([['createGame']]) expect(result.current.opening).toBe(true) diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts index b4603e2e..e8f26c7b 100644 --- a/src/hooks/useRummyTable.ts +++ b/src/hooks/useRummyTable.ts @@ -15,7 +15,9 @@ const storedOrder = (): HandOrder => (safeLocalStorage.get(ORDER_KEY) === 'rank' // What the table's chrome calls; the lobby panel adds create and join. export interface RummyTableActions { + // Seats the table; the dealer's pick deals. startTable: () => void + chooseVariant: (variant: string) => void leaveTable: () => void // Another table, from the one that just ended: a create, since the // finished one is already gone from the hub. @@ -44,6 +46,9 @@ export interface UseRummyTable extends RummyTableActions { order: HandOrder // A table has been asked for and not yet arrived. opening: boolean + // A deal has been asked for and the hub has not answered: a second ask + // would be refused and read as the first having failed. + dealing: boolean handleUpdate: (update: RummyUpdate) => void // Any refusal: whatever it was for, the table asked for did not // happen, so the ask can be made again. @@ -67,6 +72,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa const [ended, setEnded] = useState(null) const [selected, setSelected] = useState([]) const [opening, setOpening] = useState(false) + const [dealing, setDealing] = useState(false) const [order, setOrderState] = useState(storedOrder) const clear = useCallback(() => { @@ -74,8 +80,12 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa setEnded(null) setSelected([]) setOpening(false) + setDealing(false) + }, []) + const handleRejected = useCallback(() => { + setOpening(false) + setDealing(false) }, []) - const handleRejected = useCallback(() => setOpening(false), []) const handleUpdate = useCallback( (update: RummyUpdate) => { @@ -84,24 +94,23 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa setEnded(null) setSelected([]) setOpening(false) + setDealing(false) return } if (update.gameState) { setView(update.gameState.view) setSelected([]) + setDealing(false) return } if (update.gameCreated) { if (update.gameCreated.createdBy !== playerId) showNotice(`${update.gameCreated.createdBy} opened table ${update.gameCreated.gameId}`) return } - if (update.gameStarted) { - showNotice('Dealt. Draw a card to open your turn.') - return - } - if (update.turnChanged) { - // The felt lights the seat on turn and the piles light up for its - // draw; a toast at the foot of the screen would sit on the hand. + if (update.gameStarted || update.turnChanged) { + // The felt says both: who deals between deals, and the seat on turn + // with its piles lit. A toast at the foot of the screen would sit + // on the hand. return } if (update.gameEnded) { @@ -125,6 +134,13 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa }, [move]) const joinTable = useCallback((gameId: string) => move('joinGame', { gameId }), [move]) const startTable = useCallback(() => move('startGame'), [move]) + const chooseVariant = useCallback( + (variant: string) => { + setDealing(true) + move('chooseVariant', { variant }) + }, + [move] + ) const leaveTable = useCallback(() => { if (view !== null && view.phase !== 'ended') { move('leaveGame') @@ -188,12 +204,14 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa selected, order, opening, + dealing, handleUpdate, handleRejected, clear, createTable, joinTable, startTable, + chooseVariant, leaveTable, playAgain, drawStock,