From 248f046f5d15ef6975c67928653fdd1eddcf380d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:54:53 +0000 Subject: [PATCH 1/8] lobby: rummy tables (MoonBase#245) --- README.md | 10 +- src/apps/castle/components/Cards.tsx | 64 +++ src/apps/castle/components/CastleTable.tsx | 59 +-- .../lobby/__tests__/lobbyCommands.test.ts | 10 +- src/apps/lobby/components/LobbyGame.tsx | 10 +- src/apps/lobby/components/LobbyPanel.tsx | 15 +- .../components/__tests__/LobbyGame.test.tsx | 17 +- .../components/__tests__/LobbyPanel.test.tsx | 14 +- src/apps/lobby/lobbyCommands.ts | 5 +- src/apps/lobby/offers.ts | 14 +- src/apps/rummy/__tests__/rules.test.ts | 135 ++++++ .../rummy/components/RummyTable.module.css | 174 +++++++ src/apps/rummy/components/RummyTable.tsx | 427 ++++++++++++++++++ .../components/__tests__/RummyTable.test.tsx | 197 ++++++++ src/apps/rummy/rules.ts | 116 +++++ src/apps/rummy/wire.ts | 100 ++++ src/apps/stats/rollup.ts | 1 + src/hooks/__tests__/useLobby.test.tsx | 31 ++ src/hooks/__tests__/useRummyTable.test.tsx | 173 +++++++ src/hooks/useLobby.ts | 26 +- src/hooks/useRummyTable.ts | 208 +++++++++ src/utils/hubStream.ts | 4 +- 22 files changed, 1730 insertions(+), 80 deletions(-) create mode 100644 src/apps/castle/components/Cards.tsx create mode 100644 src/apps/rummy/__tests__/rules.test.ts create mode 100644 src/apps/rummy/components/RummyTable.module.css create mode 100644 src/apps/rummy/components/RummyTable.tsx create mode 100644 src/apps/rummy/components/__tests__/RummyTable.test.tsx create mode 100644 src/apps/rummy/rules.ts create mode 100644 src/apps/rummy/wire.ts create mode 100644 src/hooks/__tests__/useRummyTable.test.tsx create mode 100644 src/hooks/useRummyTable.ts diff --git a/README.md b/README.md index 00a59229..d27ec4a3 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,14 @@ The nav's **Hmm** menu links to apps hosted off muchq.com (see the list). Those are external links, not routes — their code lives in their own repos, not here. -### The lobby, golf, castle, and thoughts +### The lobby, golf, castle, rummy, and thoughts `/games` is the lobby (MoonBase#1490): the thoughts world with a panel for the room, its -players and their tables, and the room's chat, all on one stream. A table of either game opens -over the world (MoonBase#1502); `GolfTable` and `CastleTable` are the tables, `useGolfTable` -and `useCastleTable` their state over the room stream's game envelopes. Share links are +players and their tables, and the room's chat, all on one stream. A table of any game opens +over the world (MoonBase#1502); `GolfTable`, `CastleTable` and `RummyTable` (MoonBase#245) +are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state over the +room stream's game envelopes. Castle and rummy share the felt and the card faces +(`castle/components/Cards.tsx`, `CastleTable.module.css`). Share links are `/games/room/:roomId` and `/games/room/:roomId/table/:gameId`; the old `/golf` and `/castle` links redirect to them, and `/thoughts` to `/games`. Hiding the panel is how the bare world is asked for, so that choice is remembered across visits. diff --git a/src/apps/castle/components/Cards.tsx b/src/apps/castle/components/Cards.tsx new file mode 100644 index 00000000..e02ff00a --- /dev/null +++ b/src/apps/castle/components/Cards.tsx @@ -0,0 +1,64 @@ +import type { CSSProperties } from 'react' +import type { Card } from '../wire' +import { face, isRed } from '../rules' +import styles from './CastleTable.module.css' + +// A playing card as every table draws it — castle's and rummy's — in +// castle's stylesheet, which is the house style for cards on felt. + +interface CardFaceProps { + card: Card + // Present only for a card the viewer can act on: those are buttons, + // the rest are pictures. A toggle (selection) reports its state. + onClick?: () => void + toggle?: boolean + label?: string + className?: string + style?: CSSProperties +} + +// A card's face: the index in the top-left and, turned round, the +// bottom-right, the way a real card carries it — so a card mostly under +// its neighbour still says what it is — and its suit in the middle. +const CardFaceMarks = ({ card }: { card: Card }) => ( + <> + + {card.rank} + {card.suit} + + {card.suit} + + {card.rank} + {card.suit} + + +) + +export const CardFace = ({ card, onClick, toggle, label, className = '', style }: CardFaceProps) => { + const classes = `${styles.card} ${isRed(card) ? styles.red : ''} ${toggle ? styles.selected : ''} ${className}` + if (onClick === undefined) { + return ( + + + + ) + } + return ( + + ) +} + +interface CardBackProps { + onClick?: () => void + label: string +} + +export const CardBack = ({ onClick, label }: CardBackProps) => + onClick === undefined ? ( + + ) : ( + - ) -} - -interface CardBackProps { - onClick?: () => void - label: string -} - -const CardBack = ({ onClick, label }: CardBackProps) => - onClick === undefined ? ( - - ) : ( -

{GAME_BLURB.golf}

+ +

{GAME_BLURB.rummy}

{COMMAND_HINT} diff --git a/src/apps/lobby/components/__tests__/LobbyGame.test.tsx b/src/apps/lobby/components/__tests__/LobbyGame.test.tsx index 56327665..bb269532 100644 --- a/src/apps/lobby/components/__tests__/LobbyGame.test.tsx +++ b/src/apps/lobby/components/__tests__/LobbyGame.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { UseLobby } from '@/hooks/useLobby' import { fakeVoiceMesh } from '@/test/fakeVoice' import type { CastleView } from '@/apps/castle/wire' +import type { RummyView } from '@/apps/rummy/wire' import type { GameState } from '@/types/golf' import type { CommandRegistry } from '@/utils/commandRegistry' import { COMMAND_HOTKEY } from '@/utils/hotkeys' @@ -28,7 +29,8 @@ const state = { world: {}, voice: fakeVoiceMesh(), castle: { view: null as CastleView | null, ended: null, selected: [] }, - golf: { view: null as GameState | null, ended: null, peekCountdown: null } + golf: { view: null as GameState | null, ended: null, peekCountdown: null }, + rummy: { view: null as RummyView | null, ended: null, selected: [] } } as unknown as UseLobby vi.mock('@/hooks/useLobby', async importOriginal => ({ @@ -54,6 +56,7 @@ vi.mock('@/apps/thoughts/components/ThoughtsGame', () => ({ } })) vi.mock('@/apps/castle/components/CastleTable', () => ({ default: () =>
table
})) +vi.mock('@/apps/rummy/components/RummyTable', () => ({ default: () =>
rummy table
})) const openChat = vi.fn() const askBot = vi.fn() vi.mock('../RoomChat', () => ({ @@ -137,6 +140,18 @@ describe('LobbyGame', () => { expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy() }) + it('a rummy table is a table too: over the world, the panel folded', () => { + const { rerender } = render() + state.rummy.view = { gameId: 'M1' } as RummyView + rerender() + expect(screen.getByText('rummy table')).toBeTruthy() + expect(screen.queryByRole('complementary', { name: 'lobby' })).toBeNull() + state.rummy.view = null + rerender() + expect(screen.queryByText('rummy table')).toBeNull() + expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy() + }) + // The bare world is the panel hidden on purpose, and on purpose means // it stays hidden. describe('a hidden panel', () => { diff --git a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx index 3e49fbe8..4e81180e 100644 --- a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx +++ b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx @@ -19,7 +19,8 @@ const room = (over: Partial = {}): HubRoom => ({ { gameId: 'G1', game: 'castle', status: 'waiting', playerCount: 1 }, { gameId: 'G2', game: 'golf', status: 'playing', playerCount: 2 }, { gameId: 'G3', game: 'golf', status: 'waiting', playerCount: 4 }, - { gameId: 'G4', game: 'golf', status: 'waiting', playerCount: 1 } + { gameId: 'G4', game: 'golf', status: 'waiting', playerCount: 1 }, + { gameId: 'M1', game: 'rummy', status: 'waiting', playerCount: 2 } ], ...over }) @@ -43,6 +44,7 @@ const lobby = (over: Partial = {}): UseLobby => voice: fakeVoiceMesh() as unknown as UseLobby['voice'], castle: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['castle'], golf: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['golf'], + rummy: { createTable: vi.fn(), joinTable: vi.fn() } as unknown as UseLobby['rummy'], ...over }) as UseLobby @@ -76,6 +78,11 @@ describe('LobbyPanel', () => { expect(screen.getByRole('button', { name: 'Full golf G3' })).toHaveProperty('disabled', true) fireEvent.click(screen.getByRole('button', { name: 'Join golf G4' })) expect(hook.golf.joinTable).toHaveBeenCalledWith('G4') + fireEvent.click(screen.getByRole('button', { name: 'Join rummy M1' })) + expect(hook.rummy.joinTable).toHaveBeenCalledWith('M1') + expect(hook.castle.joinTable).toHaveBeenCalledTimes(1) + expect(hook.golf.joinTable).toHaveBeenCalledTimes(1) + expect(screen.getByText(/First to empty their hand wins/)).toBeTruthy() const openCastle = screen.getByRole('button', { name: 'Open a castle table' }) const openGolf = screen.getByRole('button', { name: 'Open a golf table' }) @@ -86,6 +93,10 @@ describe('LobbyPanel', () => { expect(hook.castle.createTable).toHaveBeenCalled() fireEvent.click(openGolf) expect(hook.golf.createTable).toHaveBeenCalled() + const openRummy = screen.getByRole('button', { name: 'Open a rummy table' }) + expect(openRummy.className).toBe(openCastle.className) + fireEvent.click(openRummy) + expect(hook.rummy.createTable).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Leave room' })) expect(hook.leaveRoom).toHaveBeenCalled() }) @@ -96,6 +107,7 @@ describe('LobbyPanel', () => { render() expect(screen.getByRole('button', { name: 'Open a castle table' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Open a golf table' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Open a rummy table' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Join castle G1' })).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Join golf G4' })).toHaveProperty('disabled', true) }) diff --git a/src/apps/lobby/lobbyCommands.ts b/src/apps/lobby/lobbyCommands.ts index b3545df9..a6e1dbd9 100644 --- a/src/apps/lobby/lobbyCommands.ts +++ b/src/apps/lobby/lobbyCommands.ts @@ -1,7 +1,7 @@ import type { UseLobby } from '@/hooks/useLobby' import { lobbyRoomPath } from '@/hooks/useLobby' import type { Command } from '@/utils/commandRegistry' -import { atTable, tableOffer, TABLE_SEATS } from './offers' +import { atTable, tableFor, tableOffer, TABLE_SEATS } from './offers' // What the page around the lobby does for these entries: the panel and // the chat are LobbyGame's, not the hook's. @@ -57,10 +57,11 @@ export function lobbyCommands(lobby: UseLobby, ui: LobbyUi): Command[] { if (!atTable(room, playerId)) { commands.push({ id: 'open-castle', label: 'Open a castle table', run: lobby.castle.createTable }) commands.push({ id: 'open-golf', label: 'Open a golf table', run: lobby.golf.createTable }) + commands.push({ id: 'open-rummy', label: 'Open a rummy table', run: lobby.rummy.createTable }) for (const table of room.games) { if (!tableOffer(table).open) continue const game = table.game ?? 'golf' - const join = game === 'castle' ? lobby.castle.joinTable : lobby.golf.joinTable + const join = tableFor(lobby, game).joinTable commands.push({ id: `join-${game}-${table.gameId}`, label: `Join ${game} table ${table.gameId}`, diff --git a/src/apps/lobby/offers.ts b/src/apps/lobby/offers.ts index 6f0446df..e6b53d11 100644 --- a/src/apps/lobby/offers.ts +++ b/src/apps/lobby/offers.ts @@ -1,9 +1,10 @@ -import type { HubGameSummary, HubRoom } from '@/utils/hubStream' +import type { UseLobby } from '@/hooks/useLobby' +import type { HubGameName, HubGameSummary, HubRoom } from '@/utils/hubStream' // What the lobby offers and when, read the same way by the panel's // buttons and the command menu's entries. -// Both games seat four. +// Every game seats four. export const TABLE_SEATS = 4 // How a table reads: open to join, or why not. @@ -17,3 +18,12 @@ export function tableOffer(table: HubGameSummary): { label: string; open: boolea export function atTable(room: HubRoom, playerId: string): boolean { return room.players.find(player => player.playerId === playerId)?.table !== undefined } + +// The table hook that speaks a game's vocabulary: a table is joined in its +// own game's envelope, or the hub refuses it. A summary from before the +// game was named is golf's. +export function tableFor(lobby: Pick, game: HubGameName | undefined) { + if (game === 'castle') return lobby.castle + if (game === 'rummy') return lobby.rummy + return lobby.golf +} diff --git a/src/apps/rummy/__tests__/rules.test.ts b/src/apps/rummy/__tests__/rules.test.ts new file mode 100644 index 00000000..6bf3ac71 --- /dev/null +++ b/src/apps/rummy/__tests__/rules.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { + arrangedMeld, + canDiscard, + deadwood, + describeEnding, + describeLastMove, + headlineOf, + meldsFitting, + sortHand +} from '../rules' +import type { Card, RummyView } from '../wire' + +// The UI's copy of the engine's rules only shapes the offer — the hub +// refuses in band — but an offer that disagrees with the engine is a +// button that does nothing, so the meld rules are pinned case for case +// against libs/cards/rummy's meld_test. + +const c = (spelled: string): Card => ({ rank: spelled.slice(0, -1), suit: spelled.slice(-1) }) +const cards = (...spelled: string[]) => spelled.map(c) +const faces = (list: Card[] | null) => (list === null ? null : list.map(card => card.rank + card.suit)) + +describe('arrangedMeld', () => { + it('lays three or four of a rank in suit order', () => { + expect(faces(arrangedMeld(cards('7♠', '7♣', '7♥')))).toEqual(['7♣', '7♥', '7♠']) + expect(arrangedMeld(cards('K♣', 'K♦', 'K♥', 'K♠'))).not.toBeNull() + }) + + it('refuses two cards, a repeated card, and a mix', () => { + expect(arrangedMeld(cards('7♣', '7♥'))).toBeNull() + expect(arrangedMeld(cards('7♣', '7♣', '7♥'))).toBeNull() + expect(arrangedMeld(cards('7♣', '7♥', '8♥'))).toBeNull() + expect(arrangedMeld([])).toBeNull() + }) + + it('lays a run low to high, one suit, no gaps', () => { + expect(faces(arrangedMeld(cards('9♣', '7♣', '8♣', '10♣')))).toEqual(['7♣', '8♣', '9♣', '10♣']) + expect(arrangedMeld(cards('7♣', '8♣', '10♣'))).toBeNull() + expect(arrangedMeld(cards('7♣', '8♣', '9♥'))).toBeNull() + }) + + it('runs the ace low or high but never round the corner', () => { + expect(faces(arrangedMeld(cards('3♣', 'A♣', '2♣')))).toEqual(['A♣', '2♣', '3♣']) + expect(faces(arrangedMeld(cards('A♣', 'K♣', 'Q♣')))).toEqual(['Q♣', 'K♣', 'A♣']) + expect(arrangedMeld(cards('K♣', 'A♣', '2♣'))).toBeNull() + }) + + it('reads a whole suit in rank order, the ace on top', () => { + const suit = cards('2♠', '3♠', '4♠', '5♠', '6♠', '7♠', '8♠', '9♠', '10♠', 'J♠', 'Q♠', 'K♠', 'A♠') + const run = arrangedMeld([...suit].reverse()) + expect(faces(run)?.at(0)).toBe('2♠') + expect(faces(run)?.at(-1)).toBe('A♠') + }) +}) + +describe('meldsFitting', () => { + const melds = [ + { owner: 'bob', cards: cards('4♣', '5♣', '6♣') }, + { owner: 'alice', cards: cards('9♣', '9♦', '9♥') } + ] + it('names every meld a card would grow, at either end of a run or onto a set', () => { + expect(meldsFitting(melds, c('7♣'))).toEqual([0]) + expect(meldsFitting(melds, c('3♣'))).toEqual([0]) + expect(meldsFitting(melds, c('9♠'))).toEqual([1]) + expect(meldsFitting(melds, c('8♣'))).toEqual([]) + }) +}) + +describe('sortHand', () => { + const hand = cards('K♦', '2♠', 'A♠', '10♥', 'K♠', '3♣') + it('by suit groups the suits, alternating colour, each low to high with the ace low', () => { + expect(faces(sortHand(hand, 'suit'))).toEqual(['A♠', '2♠', 'K♠', '10♥', '3♣', 'K♦']) + }) + it('by rank lines up sets', () => { + expect(faces(sortHand(hand, 'rank'))).toEqual(['A♠', '2♠', '3♣', '10♥', 'K♠', 'K♦']) + }) + it('does not reorder the hand it was given', () => { + sortHand(hand, 'rank') + expect(faces(hand)?.[0]).toBe('K♦') + }) +}) + +describe('deadwood', () => { + it('counts an ace one, pips as printed, faces ten', () => { + expect(deadwood(cards('A♠', '7♣', '10♥', 'J♦', 'Q♣', 'K♠'))).toBe(1 + 7 + 10 + 30) + }) +}) + +describe('canDiscard', () => { + const view = (over: Partial = {}): RummyView => ({ + gameId: 'G', + phase: 'playing', + players: [], + stockCount: 0, + canDrawStock: true, + discardCount: 0, + melds: [], + ...over + }) + it('refuses the card taken from the discard, unless it is all that is left', () => { + const taken = { takenDiscard: c('9♠') } + expect(canDiscard(view(taken), cards('9♠', '2♣'), c('9♠'))).toBe(false) + expect(canDiscard(view(taken), cards('9♠', '2♣'), c('2♣'))).toBe(true) + expect(canDiscard(view(taken), cards('9♠'), c('9♠'))).toBe(true) + expect(canDiscard(view(), cards('9♠', '2♣'), c('9♠'))).toBe(true) + }) +}) + +describe('describeLastMove', () => { + it('says each move, in the second person for the viewer', () => { + expect(describeLastMove({ playerId: 'bob', move: 'drawStock', cards: [] }, 'alice')).toBe('bob drew from the stock') + expect(describeLastMove({ playerId: 'alice', move: 'drawDiscard', cards: cards('9♠') }, 'alice')).toBe('You took 9♠') + expect(describeLastMove({ playerId: 'bob', move: 'meld', cards: cards('7♥', '8♥', '9♥'), meldIndex: 0 }, 'alice')).toBe( + 'bob melded 7♥ 8♥ 9♥' + ) + expect(describeLastMove({ playerId: 'bob', move: 'layOff', cards: cards('10♥'), meldIndex: 0 }, 'alice')).toBe('bob laid off 10♥') + expect(describeLastMove({ playerId: 'bob', move: 'discard', cards: cards('K♣') }, 'alice')).toBe('bob discarded K♣') + }) +}) + +describe('the ending', () => { + const won = { winner: 'alice', points: 42, scores: [] } + it('reads from each chair', () => { + expect(headlineOf(won, 'alice')).toBe('You won!') + expect(headlineOf(won, 'bob')).toBe('alice wins') + expect(describeEnding(won, 'alice')).toBe('You went out and score 42 points.') + expect(describeEnding(won, 'bob')).toBe('alice went out and scores 42 points.') + expect(describeEnding({ winner: 'alice', points: 1, scores: [] }, 'bob')).toBe('alice went out and scores 1 point.') + }) + it('names nobody for a table that broke up', () => { + const broke = { points: 0, scores: [] } + expect(headlineOf(broke, 'alice')).toBe('The table broke up') + expect(describeEnding(broke, 'alice')).toBe('Nobody went out.') + }) +}) diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css new file mode 100644 index 00000000..62db72c5 --- /dev/null +++ b/src/apps/rummy/components/RummyTable.module.css @@ -0,0 +1,174 @@ +/* Rummy's own furniture on castle's felt (CastleTable.module.css): the + middle holds the melds as well as the two piles, and the viewer's hand + has a line of tools under it. */ + +/* The middle is wider than castle's pile: the melds wrap beneath the + piles, and a table of them wants the room. */ +.middle { + max-width: min(26rem, 90%); + gap: 0.6rem; +} + +.melds { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.4rem 0.6rem; + /* A long game's melds scroll inside the middle rather than pushing the + chairs off the felt. */ + max-height: 11rem; + overflow-y: auto; +} + +.noMelds { + font-size: 0.8rem; +} + +/* A meld is its cards overlapped tight, drawn smaller than a hand: it is + read, not held. */ +.meld { + display: inline-flex; + padding: 0.2rem 0.3rem; + border-radius: 0.4rem; + border: 2px solid transparent; + background: none; + font: inherit; + color: inherit; + zoom: 0.72; +} + +button.meld { + cursor: pointer; +} + +.meldSlot { + position: relative; + display: block; + margin-left: -1.9rem; +} + +.meldSlot:first-child { + margin-left: 0; +} + +/* A meld the picked card would grow: lit, the place to tap. */ +.fits { + border-color: #f0c96a; + background: rgba(240, 201, 106, 0.15); + animation: fits 1.2s ease-in-out infinite alternate; +} + +.fits:hover:enabled, +.fits:focus-visible { + background: rgba(240, 201, 106, 0.3); +} + +@keyframes fits { + from { + box-shadow: 0 0 0 0 rgba(240, 201, 106, 0); + } + to { + box-shadow: 0 0 0.6rem 0.1rem rgba(240, 201, 106, 0.55); + } +} + +/* The meld the last move made or grew, while the moment lasts. */ +.justLaid { + border-color: rgba(255, 255, 255, 0.5); +} + +/* A pile to draw from, on the viewer's draw: the one thing to do. */ +.drawable { + cursor: pointer; + outline: 2px solid #f0c96a; + outline-offset: 2px; + animation: fits 1.2s ease-in-out infinite alternate; +} + +/* The card just taken from the discard: it can't go straight back. */ +.taken { + outline: 2px dashed #f0c96a; + outline-offset: -4px; +} + +/* A hand of ten or eleven is wider than castle's three. */ +.hand { + --overlap-max: 1.7rem; +} + +.handTools { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.85rem; +} + +.sort { + display: inline-flex; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 0.4rem; + overflow: hidden; +} + +.sortButton { + padding: 0.2rem 0.55rem; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.8); + font: inherit; + cursor: pointer; + touch-action: manipulation; +} + +.sortButton[aria-pressed='true'] { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.scores { + border-collapse: collapse; + min-width: 12rem; +} + +.scores th, +.scores td { + padding: 0.2rem 0.6rem; + text-align: left; + font-weight: normal; +} + +.scores td { + text-align: right; + color: rgba(255, 255, 255, 0.75); +} + +.winnerRow th, +.winnerRow td { + color: #f0c96a; + font-weight: 600; +} + +@media (max-width: 600px), (max-height: 900px) { + .hand { + --overlap-max: 1.4rem; + } + + .melds { + max-height: 8rem; + } +} + +@media (max-width: 600px) { + .middle { + max-width: 92%; + } +} + +@media (prefers-reduced-motion: reduce) { + .fits, + .drawable { + animation: none; + } +} diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx new file mode 100644 index 00000000..04a56204 --- /dev/null +++ b/src/apps/rummy/components/RummyTable.tsx @@ -0,0 +1,427 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import type { CSSProperties, KeyboardEvent, ReactNode } from 'react' +import type { RummyTableActions } from '@/hooks/useRummyTable' +import type { HandOrder } from '../rules' +import type { Card, RummyGameEnded, RummyLastMove, RummyPlayer, RummyView } from '../wire' +import { + arrangedMeld, + canDiscard, + deadwood, + describeEnding, + describeLastMove, + enteredSince, + face, + headlineOf, + meldsFitting, + seatOf, + sortHand +} from '../rules' +import { CardBack, CardFace } from '@/apps/castle/components/Cards' +import { clockOf, fromViewer } from '@/apps/castle/seating' +import felt from '@/apps/castle/components/CastleTable.module.css' +import styles from './RummyTable.module.css' + +// The rummy table from the viewer's chair, seen from above, on castle's +// felt: the viewer at 6 o'clock with their moves under their hand, the +// others around the ring in turn order. The middle is the stock, the +// discard pile and the melds everyone lays off onto. +// +// A turn reads as three steps, and the table only ever lights up the one +// the viewer is on: the two piles while there is a card to draw; then the +// hand, with the meld and discard buttons beneath it and every meld a +// picked card would grow lit as a place to lay it. Every rule offered is +// the engine's too — the hub refuses in band and the lobby says why. + +export interface RummyTableProps { + playerId: string + connected: boolean + view: RummyView + table: RummyTableActions & { ended: RummyGameEnded | null; selected: string[]; order: HandOrder; opening: boolean } + children?: ReactNode +} + +// Another seat's hand is backs: past this many, the count says the rest. +const SHOWN_BACKS = 7 +// The fan spans at most this many degrees, however many cards. +const FAN_SPREAD = 24 +const FAN_STEP = 4 + +// The key that restarts the last-move moment when a new one lands. A view +// repeats the last move until the next replaces it. +const moveSignature = (move: RummyLastMove): string => `${move.playerId}:${move.move}:${move.cards.map(face).join(',')}:${move.meldIndex ?? ''}` + +const RummyTable = ({ playerId, connected, view, table, children }: RummyTableProps) => { + const { ended, opening, selected, order } = table + const headingRef = useRef(null) + useEffect(() => { + headingRef.current?.focus() + }, []) + const [endingRead, setEndingRead] = useState(null) + const [faded, setFaded] = useState(null) + const playAgainRef = useRef(null) + const endingRef = useRef(null) + + const me = seatOf(view, playerId) + const myHand = sortHand(me?.hand ?? [], order) + const myTurn = view.phase === 'playing' && view.currentPlayerId === playerId + const drawing = myTurn && view.stage === 'draw' + const laying = myTurn && view.stage === 'play' + + // Cards that just entered the viewer's hand slide in, so a draw reads + // as the card arriving. A new table's deal is not an arrival. + const handSig = `${view.gameId}:${myHand.map(face).join(',')}` + const [handMark, setHandMark] = useState<{ sig: string; faces: string[]; entered: number[]; gen: number }>({ + sig: '', + faces: [], + entered: [], + gen: 0 + }) + if (handMark.sig !== handSig) { + setHandMark({ + sig: handSig, + faces: myHand.map(face), + entered: handMark.sig.startsWith(`${view.gameId}:`) ? enteredSince(handMark.faces, myHand) : [], + gen: handMark.gen + 1 + }) + } + + const picked = selected.map(f => myHand.find(card => face(card) === f)).filter((card): card is Card => card !== undefined) + const meld = laying && picked.length >= 3 ? arrangedMeld(picked) : null + const single = laying && picked.length === 1 ? picked[0] : null + const fitting = single === null ? [] : meldsFitting(view.melds, single) + const discardable = single !== null && canDiscard(view, myHand, single) + + const showEnding = view.phase === 'ended' && ended !== null && endingRead !== view.gameId + const keepFocusIn = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + const focusable = endingRef.current?.querySelectorAll('button:not(:disabled)') + if (focusable === undefined || focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + const dismissEnding = () => { + setEndingRead(view.gameId) + headingRef.current?.focus() + } + useEffect(() => { + if (showEnding) playAgainRef.current?.focus() + }, [showEnding]) + + const moment = view.lastMove !== undefined && `${view.gameId}:${moveSignature(view.lastMove)}` !== faded ? view.lastMove : undefined + + const hint = (() => { + if (view.phase === 'waiting') return view.players.length < 2 ? 'Waiting for a second seat.' : '' + if (drawing) { + if (view.discardTop === undefined) return 'Draw from the stock.' + return view.canDrawStock ? `Draw from the stock, or take the ${face(view.discardTop)}.` : `The stock is out: take the ${face(view.discardTop)}.` + } + if (laying) { + if (single !== null && fitting.length > 0) return `Tap a lit meld to lay off ${face(single)}, or discard it.` + if (picked.length >= 3 && meld === null) return 'Those cards are not a set or a run.' + return 'Meld or lay off if you can, then discard to end your turn.' + } + return '' + })() + + const status = ( +
+

+ {myTurn ? 'Your turn' : ''} +

+

+ {hint} +

+

+ {moment !== undefined && ( + setFaded(`${view.gameId}:${moveSignature(moment)}`)} + > + {describeLastMove(moment, playerId)} + + )} +

+
+ ) + + const actions = (() => { + if (view.phase === 'waiting') { + return ( + + ) + } + if (drawing) { + return ( + <> + + + + ) + } + if (laying) { + return ( + <> + + + {single !== null && !canDiscard(view, myHand, single) && ( +

You just took {face(single)}: it can’t go straight back.

+ )} + + ) + } + if (view.phase === 'ended' && !showEnding) { + return ( + <> + {ended !== null && ( + + )} + + + ) + } + return null + })() + + const renderSeat = (seat: RummyPlayer, clock: number) => { + const mine = seat.playerId === playerId + const onTurn = view.phase === 'playing' && view.currentPlayerId === seat.playerId + const label = mine ? `${seat.playerId} (you)` : seat.playerId + const whose = mine ? 'Your' : `${seat.playerId}'s` + const hand = mine ? myHand : seat.hand + const shown: (Card | null)[] = hand.length > 0 ? hand : Array.from({ length: Math.min(seat.handCount, SHOWN_BACKS) }, () => null) + const step = Math.min(FAN_STEP, FAN_SPREAD / Math.max(shown.length, 1)) + return ( +
+

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

+
+
+
+
+ {shown.map((card, i, all) => { + const angle = (i - (all.length - 1) / 2) * step + const entered = mine && handMark.entered.includes(i) + const taken = card !== null && laying && mine && view.takenDiscard !== undefined && face(view.takenDiscard) === face(card) + return ( + + {card === null ? ( + + ) : ( + table.toggleCard(card) : undefined} + /> + )} + + ) + })} +
+
+
+
+ {mine && view.phase !== 'waiting' && ( +
+ Deadwood {deadwood(myHand)} + + {(['suit', 'rank'] as const).map(by => ( + + ))} + +
+ )} + {mine &&
{actions}
} +
+ ) + } + + return ( +
+
+

+ Rummy · {view.gameId} +

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

+ {view.phase === 'ended' && ended !== null ? describeEnding(ended, playerId) : ''} +

+ {showEnding && + ended !== null && + createPortal( +
{ + if (event.key === 'Escape') { + event.preventDefault() + dismissEnding() + } + keepFocusIn(event) + }} + > +
+
{ended.winner === playerId ? '🏆' : ended.winner === undefined ? '🤝' : '🃏'}
+

+ {headlineOf(ended, playerId)} +

+

{describeEnding(ended, playerId)}

+ {ended.scores.length > 0 && ( + + + + {ended.scores.map(score => ( + + + + + ))} + +
Cards left in hand
{score.playerId === playerId ? 'You' : score.playerId}{score.playerId === ended.winner ? `+${ended.points}` : `${score.deadwood} left`}
+ )} +
+ + +
+ +
+
, + document.body + )} +
+ {fromViewer(view.players, playerId).map((seat, i, all) => renderSeat(seat, clockOf(all.length, i)))} + {(view.phase === 'playing' || view.phase === 'ended') && ( +
+
+
+ {drawing && view.canDrawStock ? ( +
+
+ {view.discardTop === undefined ? ( +
discard
+ ) : ( + + )} + {view.discardCount > 0 && {view.discardCount}} +
+
+
+ {view.melds.length === 0 ? ( +

No melds yet

+ ) : ( + view.melds.map((tableMeld, m) => { + const fits = fitting.includes(m) + const cards = tableMeld.cards.map((card, i) => ( + + + + )) + const lastLaid = view.lastMove?.meldIndex === m && moment !== undefined + const classes = `${styles.meld} ${fits ? styles.fits : ''} ${lastLaid ? styles.justLaid : ''}` + const named = `${tableMeld.cards.map(face).join(' ')}, ${tableMeld.owner === playerId ? 'yours' : `${tableMeld.owner}'s`}` + return fits && single !== null ? ( + + ) : ( + + {cards} + + ) + }) + )} +
+
+ )} +
+ {children} +
+ ) +} + +export default RummyTable diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx new file mode 100644 index 00000000..4f2d7968 --- /dev/null +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -0,0 +1,197 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import RummyTable from '../RummyTable' +import type { RummyTableProps } from '../RummyTable' +import type { Card, RummyPlayer, RummyView } from '../../wire' + +// The table from one chair, over a fake hook: what is offered at each +// step of a turn, and what a tap sends. + +const c = (spelled: string): Card => ({ rank: spelled.slice(0, -1), suit: spelled.slice(-1) }) + +const seat = (playerId: string, hand: string[] = []): RummyPlayer => ({ + playerId, + handCount: hand.length || 10, + hand: hand.map(c) +}) + +const myHand = ['K♦', '7♥', '7♣', '8♥', '9♥', '2♠'] + +const view = (over: Partial = {}): RummyView => ({ + gameId: 'M1', + phase: 'playing', + players: [seat('alice', myHand), seat('bob')], + currentPlayerId: 'alice', + stage: 'play', + stockCount: 30, + canDrawStock: true, + discardCount: 3, + discardTop: c('Q♠'), + melds: [ + { owner: 'bob', cards: [c('4♣'), c('5♣'), c('6♣')] }, + { owner: 'bob', cards: [c('J♣'), c('J♦'), c('J♥')] } + ], + ...over +}) + +const table = (over: Partial = {}): RummyTableProps['table'] => ({ + ended: null, + selected: [], + order: 'suit', + opening: false, + startTable: vi.fn(), + leaveTable: vi.fn(), + playAgain: vi.fn(), + drawStock: vi.fn(), + drawDiscard: vi.fn(), + toggleCard: vi.fn(), + meldSelected: vi.fn(), + layOffSelected: vi.fn(), + discardSelected: vi.fn(), + setOrder: vi.fn(), + ...over +}) + +const mountWith = (v: RummyView, over: Partial = {}, connected = true) => { + const t = table(over) + const rendered = render() + return { ...rendered, t } +} + +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 })) + expect(screen.getByText('Waiting for a second seat.')).toBeDefined() + expect(screen.getByRole('button', { name: 'Deal' })).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' })) + expect(pair.t.startTable).toHaveBeenCalledTimes(1) + expect(solo.t.startTable).not.toHaveBeenCalled() + }) + + it('on the draw, both piles are the moves and the hand is not', () => { + const { t } = mountWith(view({ stage: 'draw' })) + expect(screen.getByText('Draw from the stock, or take the Q♠.')).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: 'draw from the stock, 30 left' })) + fireEvent.click(screen.getByRole('button', { name: 'take Q♠ from the discard pile' })) + fireEvent.click(screen.getByRole('button', { name: 'Draw from the stock' })) + fireEvent.click(screen.getByRole('button', { name: 'Take Q♠' })) + expect(t.drawStock).toHaveBeenCalledTimes(2) + expect(t.drawDiscard).toHaveBeenCalledTimes(2) + expect(myHandGroup().queryAllByRole('button')).toHaveLength(0) + expect(screen.queryByRole('button', { name: /^Meld/ })).toBeNull() + }) + + it('with the stock out, the discard is the only draw', () => { + mountWith(view({ stage: 'draw', stockCount: 0, canDrawStock: false })) + expect(screen.getByText('The stock is out: take the Q♠.')).toBeDefined() + expect(screen.queryByRole('button', { name: /draw from the stock,/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Draw from the stock' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Take Q♠' })).toHaveProperty('disabled', false) + }) + + it('off turn, nothing on the table is a button', () => { + mountWith(view({ currentPlayerId: 'bob', stage: 'draw' })) + expect(screen.queryByRole('button', { name: /draw from the stock/i })).toBeNull() + expect(screen.queryByRole('button', { name: /take Q♠/i })).toBeNull() + expect(myHandGroup().queryAllByRole('button')).toHaveLength(0) + expect(screen.getByRole('region', { name: 'bob, to draw' })).toBeDefined() + }) + + it('shows your faces and only a count of anyone else’s', () => { + mountWith(view()) + expect(myHandGroup().getAllByRole('button')).toHaveLength(myHand.length) + const bobs = within(screen.getByRole('group', { name: "bob's hand" })) + expect(bobs.getAllByRole('img', { name: 'hand card' }).length).toBeGreaterThan(0) + expect(bobs.queryByText('K')).toBeNull() + expect(screen.getByText(/10 in hand/)).toBeDefined() + }) + + it('lays the hand out in the order asked for, and the toggle asks', () => { + const { t, rerender } = mountWith(view()) + const faces = () => myHandGroup().getAllByRole('button').map(b => b.getAttribute('aria-label')) + expect(faces()).toEqual(['2♠', '7♥', '8♥', '9♥', '7♣', 'K♦']) + fireEvent.click(screen.getByRole('button', { name: 'by rank' })) + expect(t.setOrder).toHaveBeenCalledWith('rank') + rerender() + expect(faces()).toEqual(['2♠', '7♥', '7♣', '8♥', '9♥', 'K♦']) + expect(screen.getByRole('button', { name: 'by rank' }).getAttribute('aria-pressed')).toBe('true') + // Deadwood is the hand's cost, for deciding what to throw. + expect(screen.getByText('Deadwood 43')).toBeDefined() + }) + + it('a tap picks a card, and three that make a meld arm the meld button', () => { + const { t, rerender } = mountWith(view()) + fireEvent.click(myHandGroup().getByRole('button', { name: '8♥' })) + expect(t.toggleCard).toHaveBeenCalledWith(c('8♥')) + expect(screen.getByRole('button', { name: 'Meld' })).toHaveProperty('disabled', true) + rerender() + const meld = screen.getByRole('button', { name: 'Meld 7♥ 8♥ 9♥' }) + fireEvent.click(meld) + expect(t.meldSelected).toHaveBeenCalledTimes(1) + // Three that are not a meld say so rather than arm a refusal. + rerender() + expect(screen.getByRole('button', { name: 'Meld' })).toHaveProperty('disabled', true) + expect(screen.getByText('Those cards are not a set or a run.')).toBeDefined() + }) + + it('one card picked lights the melds it grows, and a tap on one lays it off there', () => { + const { t } = mountWith(view(), { selected: ['7♣'] }) + expect(screen.getByText('Tap a lit meld to lay off 7♣, or discard it.')).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: "lay off 7♣ on 4♣ 5♣ 6♣, bob's" })) + expect(t.layOffSelected).toHaveBeenCalledWith(0) + // The jacks are no place for a seven: a picture, not a button. + expect(screen.getByRole('img', { name: "J♣ J♦ J♥, bob's" })).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: 'Discard 7♣' })) + expect(t.discardSelected).toHaveBeenCalledTimes(1) + }) + + it('the card just taken from the discard is marked and cannot be thrown back', () => { + const v = view({ takenDiscard: c('2♠') }) + mountWith(v, { selected: ['2♠'] }) + expect(myHandGroup().getByRole('button', { name: '2♠, just taken' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Discard 2♠' })).toHaveProperty('disabled', true) + expect(screen.getByText(/can’t go straight back/)).toBeDefined() + }) + + it('offline, nothing is armed', () => { + mountWith(view(), { selected: ['7♣'] }, false) + expect(myHandGroup().queryAllByRole('button')).toHaveLength(0) + expect(screen.getByRole('button', { name: 'Discard 7♣' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: "lay off 7♣ on 4♣ 5♣ 6♣, bob's" })).toHaveProperty('disabled', true) + }) + + it('says the last move', () => { + mountWith(view({ currentPlayerId: 'bob', lastMove: { playerId: 'bob', move: 'drawDiscard', cards: [c('3♥')] } })) + 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 } + ) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.getByRole('heading', { name: 'bob wins' })).toBeDefined() + expect(dialog.getByText('bob went out and scores 43 points.')).toBeDefined() + expect(dialog.getByRole('row', { name: 'You 43 left' })).toBeDefined() + expect(dialog.getByRole('row', { name: 'bob +43' })).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 new file mode 100644 index 00000000..85dc503d --- /dev/null +++ b/src/apps/rummy/rules.ts @@ -0,0 +1,116 @@ +// What the UI derives from a rummy view: which cards make a meld, which +// melds a card would grow, how a hand is sorted, and how to say what just +// happened. The engine (MoonBase libs/cards/rummy) is the referee; these +// 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 { face } from '@/apps/castle/rules' + +export { face, isRed, enteredSince } from '@/apps/castle/rules' + +// Ace low, which is how a hand reads and how the ace counts; runs also +// take it above the king. +const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] +// The engine's suit order, which is how it lays a set. +const SET_SUITS = ['♣', '♦', '♥', '♠'] +// A hand sorted by suit alternates colour, so neighbouring suits are told +// apart at a glance. +const HAND_SUITS = ['♠', '♥', '♣', '♦'] + +const rankOf = (card: Card, aceHigh = false) => { + const at = RANKS.indexOf(card.rank) + return aceHigh && at === 0 ? RANKS.length : at +} + +// Laid low to high with the ace at one end, if that is a run. +const asRun = (cards: Card[], aceHigh: boolean): Card[] | null => { + const run = [...cards].sort((a, b) => rankOf(a, aceHigh) - rankOf(b, aceHigh)) + for (let i = 1; i < run.length; i++) { + if (rankOf(run[i], aceHigh) !== rankOf(run[i - 1], aceHigh) + 1) return null + } + return run +} + +// The cards as they would lie on the table if they make a meld, else +// null: three or four of a rank in suit order, or three or more of a suit +// in sequence, the ace low or high but never both. +export function arrangedMeld(cards: Card[]): Card[] | null { + if (cards.length < 3 || new Set(cards.map(face)).size !== cards.length) return null + if (cards.every(card => card.rank === cards[0].rank)) { + return [...cards].sort((a, b) => SET_SUITS.indexOf(a.suit) - SET_SUITS.indexOf(b.suit)) + } + if (!cards.every(card => card.suit === cards[0].suit)) return null + return asRun(cards, true) ?? asRun(cards, false) +} + +// The places of every meld on the table this card would grow. +export function meldsFitting(melds: RummyMeld[], card: Card): number[] { + return melds.flatMap((meld, i) => (arrangedMeld([...meld.cards, card]) === null ? [] : [i])) +} + +export type HandOrder = 'suit' | 'rank' + +// A copy of the hand in the order asked for: by suit for runs, by rank +// for sets. The hub keeps the hand in the order it arrived; this is only +// how it is laid out. +export function sortHand(hand: Card[], order: HandOrder): Card[] { + const bySuit = (a: Card, b: Card) => HAND_SUITS.indexOf(a.suit) - HAND_SUITS.indexOf(b.suit) + const byRank = (a: Card, b: Card) => rankOf(a) - rankOf(b) + return [...hand].sort(order === 'suit' ? (a, b) => bySuit(a, b) || byRank(a, b) : (a, b) => byRank(a, b) || bySuit(a, b)) +} + +// What a card left in hand costs at the end. +export function cardPoints(card: Card): number { + const at = RANKS.indexOf(card.rank) + return at >= 10 ? 10 : at + 1 +} + +export function deadwood(hand: Card[]): number { + return hand.reduce((sum, card) => sum + cardPoints(card), 0) +} + +// The card just taken from the discard pile may not go straight back, +// unless it is all the hand has left. +export function canDiscard(view: RummyView, hand: Card[], card: Card): boolean { + return view.takenDiscard === undefined || face(view.takenDiscard) !== face(card) || hand.length === 1 +} + +const you = (id: string, viewer: string) => (id === viewer ? 'You' : id) + +// The last move, as a sentence for the table. +export function describeLastMove(move: RummyLastMove, viewer: string): string { + const who = you(move.playerId, viewer) + const faces = move.cards.map(face).join(' ') + switch (move.move) { + case 'drawStock': + return `${who} drew from the stock` + case 'drawDiscard': + return `${who} took ${faces}` + case 'meld': + return `${who} melded ${faces}` + case 'layOff': + return `${who} laid off ${faces}` + case 'discard': + return `${who} discarded ${faces}` + } +} + +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)}.` +} + +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 seatOf(view: RummyView, playerId: string): RummyPlayer | undefined { + return view.players.find(p => p.playerId === playerId) +} diff --git a/src/apps/rummy/wire.ts b/src/apps/rummy/wire.ts new file mode 100644 index 00000000..dfe2c8a0 --- /dev/null +++ b/src/apps/rummy/wire.ts @@ -0,0 +1,100 @@ +// Rummy's vocabulary on the room stream, mirroring MoonBase's +// 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. + +import type { Card } from '@/apps/castle/wire' + +export type { Card } + +export type RummyPhase = 'waiting' | '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. +export type RummyStage = 'draw' | 'play' + +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. + hand: Card[] +} + +// Cards on the table, laid low to high for a run, by suit for a set. +// Melds only grow and never move, so a lay-off names one by its place. +export interface RummyMeld { + owner: string + cards: Card[] +} + +export type RummyMoveKind = 'drawStock' | 'drawDiscard' | 'meld' | 'layOff' | 'discard' + +// The table's most recent move: nothing for a stock draw (nobody else +// sees it), the card taken, the meld as laid, the card laid off, the card +// discarded. The seat named may have left since. +export interface RummyLastMove { + playerId: string + move: RummyMoveKind + cards: Card[] + meldIndex?: number +} + +export interface RummyView { + gameId: string + phase: RummyPhase + players: RummyPlayer[] + currentPlayerId?: string + stage?: RummyStage + stockCount: number + // Whether a stock draw would take a card: the stock has one, or the + // discard pile has cards under its top to turn over. + canDrawStock: boolean + discardCount: number + discardTop?: Card + // Taken from the discard pile this turn: it may not go straight back + // unless it is the last card in hand. + takenDiscard?: Card + melds: RummyMeld[] + 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. +export interface RummyGameEnded { + winner?: string + points: number + scores: RummyScore[] +} + +// The rummy update union's JSON encoding: exactly one member present. +export interface RummyUpdate { + gameJoined?: { view: RummyView } + gameState?: { view: RummyView } + gameCreated?: { gameId: string; createdBy?: string } + gameStarted?: Record + turnChanged?: { playerId: string } + gameEnded?: RummyGameEnded + gameLeft?: { gameId: string } +} + +// What each move carries. Every card is named (MoonBase #1505): the hub +// matches it against the hand it holds rather than trusting a slot. +export interface RummyMovePayloads { + createGame: undefined + joinGame: { gameId: string } + startGame: undefined + leaveGame: undefined + drawStock: undefined + drawDiscard: undefined + meld: { cards: Card[] } + layOff: { card: Card; meldIndex: number } + discard: { card: Card } +} + +export type RummyMoveName = keyof RummyMovePayloads diff --git a/src/apps/stats/rollup.ts b/src/apps/stats/rollup.ts index 7aadcc2b..b21a8744 100644 --- a/src/apps/stats/rollup.ts +++ b/src/apps/stats/rollup.ts @@ -300,6 +300,7 @@ export function rollupServices(services: StatsServices | null | undefined): Serv export const VARIANT_LABELS: Record = { golf: 'Golf', castle: 'Castle', + rummy: 'Rummy', other: 'Other', } diff --git a/src/hooks/__tests__/useLobby.test.tsx b/src/hooks/__tests__/useLobby.test.tsx index 24c911eb..8e011679 100644 --- a/src/hooks/__tests__/useLobby.test.tsx +++ b/src/hooks/__tests__/useLobby.test.tsx @@ -205,6 +205,37 @@ describe('useLobby', () => { expect(pathname()).toBe('/games/room/R1') }) + it('a rummy table swaps in on its own envelope, names the table in the URL, and a reconnect drops it', async () => { + const { result, ws, pathname } = await open() + act(() => ws.receive('roomState', roomState('R1'))) + act(() => result.current.rummy.createTable()) + expect(ws.lastSent()).toEqual({ event: 'rummy', payload: { move: { createGame: {} } } }) + const rummyView = { gameId: 'M1', phase: 'waiting', players: [], stockCount: 0, canDrawStock: false, discardCount: 0, melds: [] } + act(() => ws.receive('rummy', { update: { gameJoined: { view: rummyView } } })) + expect(result.current.rummy.view?.gameId).toBe('M1') + expect(result.current.castle.view).toBeNull() + expect(result.current.golf.view).toBeNull() + expect(pathname()).toBe('/games/room/R1/table/M1') + expect(result.current.world.isConnected).toBe(true) + // Its moves ride its envelope, as the wire spells them. + act(() => result.current.rummy.drawStock()) + expect(ws.lastSent()).toEqual({ event: 'rummy', payload: { move: { drawStock: {} } } }) + // A refusal frees the ask, whichever game asked. + act(() => result.current.rummy.playAgain()) + expect(result.current.rummy.opening).toBe(true) + act(() => ws.receive('commandRejected', { reason: 'leave your current game first' })) + expect(result.current.rummy.opening).toBe(false) + + act(() => ws.receive('roomLeft', { roomId: 'R1' })) + expect(result.current.rummy.view).toBeNull() + }) + + it('a share link to a rummy table sits at it in rummy’s envelope', async () => { + const { ws } = await open({ permalinkRoomId: 'R1', permalinkGameId: 'M1' }, '/games/room/R1/table/M1') + act(() => ws.receive('roomState', roomState('R1', [{ gameId: 'M1', game: 'rummy', status: 'waiting', playerCount: 1 }]))) + expect(ws.lastSent()).toEqual({ event: 'rummy', payload: { move: { joinGame: { gameId: 'M1' } } } }) + }) + it('joining a listed golf table sends its join, and the table answers', async () => { const { result, ws } = await open() act(() => ws.receive('roomState', roomState('R1', [{ gameId: 'G7', game: 'golf', status: 'waiting', playerCount: 1 }]))) diff --git a/src/hooks/__tests__/useRummyTable.test.tsx b/src/hooks/__tests__/useRummyTable.test.tsx new file mode 100644 index 00000000..149ca504 --- /dev/null +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -0,0 +1,173 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useRummyTable } from '../useRummyTable' +import type { Card, RummyPlayer, RummyView } from '@/apps/rummy/wire' + +// Rummy's moves name cards (MoonBase #1505): what goes on the wire is the +// card the viewer tapped, read out of the hand on screen, never a slot — +// the hand is shown sorted, so a slot would be the wrong card anyway. + +const c = (spelled: string): Card => ({ rank: spelled.slice(0, -1), suit: spelled.slice(-1) }) + +const seat = (playerId: string, hand: string[] = []): RummyPlayer => ({ + playerId, + handCount: hand.length || 10, + hand: hand.map(c) +}) + +// alice is not the first seat, so a payload built from the wrong seat +// cannot pass by coincidence. +const view = (over: Partial = {}): RummyView => ({ + gameId: 'GAME01', + phase: 'playing', + players: [seat('bob'), seat('alice', ['7♥', '8♥', '9♥', 'K♣'])], + currentPlayerId: 'alice', + stage: 'play', + stockCount: 30, + canDrawStock: true, + discardCount: 1, + discardTop: c('2♠'), + melds: [{ owner: 'bob', cards: [c('4♣'), c('5♣'), c('6♣')] }], + ...over +}) + +describe('useRummyTable', () => { + beforeEach(() => window.localStorage.clear()) + + const mount = () => { + const move = vi.fn() + const showNotice = vi.fn() + const onLeft = vi.fn() + const hook = renderHook(() => useRummyTable({ playerId: 'alice', move, showNotice, onLeft })) + const receive = (update: Parameters[0]) => + act(() => hook.result.current.handleUpdate(update)) + return { ...hook, move, showNotice, onLeft, receive } + } + + it('a meld names the selected cards, in the order they were picked', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.toggleCard(c('9♥'))) + act(() => result.current.toggleCard(c('7♥'))) + act(() => result.current.toggleCard(c('8♥'))) + expect(result.current.selected).toEqual(['9♥', '7♥', '8♥']) + act(() => result.current.meldSelected()) + // The whole transcript: one move. + expect(move.mock.calls).toEqual([['meld', { cards: [c('9♥'), c('7♥'), c('8♥')] }]]) + expect(result.current.selected).toEqual([]) + }) + + it('a second tap on a card puts it back', () => { + const { result, receive } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.toggleCard(c('K♣'))) + act(() => result.current.toggleCard(c('K♣'))) + expect(result.current.selected).toEqual([]) + }) + + it('a lay-off and a discard each name one card', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.toggleCard(c('K♣'))) + act(() => result.current.layOffSelected(0)) + act(() => result.current.toggleCard(c('K♣'))) + act(() => result.current.discardSelected()) + expect(move.mock.calls).toEqual([ + ['layOff', { card: c('K♣'), meldIndex: 0 }], + ['discard', { card: c('K♣') }] + ]) + }) + + it('a lay-off or discard with two cards picked sends nothing', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.toggleCard(c('K♣'))) + act(() => result.current.toggleCard(c('7♥'))) + act(() => result.current.layOffSelected(0)) + act(() => result.current.discardSelected()) + expect(move).not.toHaveBeenCalled() + }) + + it('a selection naming a card the hand no longer holds sends nothing, not the part that is left', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view() } }) + // A new hand and two taps in one batch: the selection outlives the + // hand it was made against, which is the one way it can. + act(() => { + result.current.handleUpdate({ gameState: { view: view({ players: [seat('bob'), seat('alice', ['7♥', 'K♣'])] }) } }) + result.current.toggleCard(c('7♥')) + result.current.toggleCard(c('9♥')) + }) + act(() => result.current.meldSelected()) + expect(move).not.toHaveBeenCalled() + // Cleared, so the table stops offering a meld it will not send. + expect(result.current.selected).toEqual([]) + }) + + it('a new view clears the selection', () => { + const { result, receive } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.toggleCard(c('K♣'))) + receive({ gameState: { view: view() } }) + expect(result.current.selected).toEqual([]) + }) + + it('the draws are bare moves', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view({ stage: 'draw' }) } }) + act(() => result.current.drawStock()) + act(() => result.current.drawDiscard()) + expect(move.mock.calls).toEqual([['drawStock'], ['drawDiscard']]) + }) + + it('the hand order is the viewer’s, and outlives the table', () => { + const first = mount() + expect(first.result.current.order).toBe('suit') + act(() => first.result.current.setOrder('rank')) + expect(first.result.current.order).toBe('rank') + first.unmount() + expect(mount().result.current.order).toBe('rank') + }) + + it('a turn is not a toast; the deal and another table opening are', () => { + const { receive, showNotice } = mount() + receive({ turnChanged: { playerId: 'alice' } }) + expect(showNotice).not.toHaveBeenCalled() + receive({ gameStarted: {} }) + 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']]) + }) + + 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') + act(() => result.current.playAgain()) + expect(move.mock.calls).toEqual([['createGame']]) + expect(result.current.opening).toBe(true) + act(() => result.current.handleRejected()) + expect(result.current.opening).toBe(false) + receive({ gameJoined: { view: view({ gameId: 'GAME02', phase: 'waiting' }) } }) + expect(result.current.view?.gameId).toBe('GAME02') + expect(result.current.ended).toBeNull() + }) + + it('leaving a live table asks the hub; leaving an ended one only lets go of the view', () => { + const live = mount() + live.receive({ gameJoined: { view: view() } }) + act(() => live.result.current.leaveTable()) + expect(live.move.mock.calls).toEqual([['leaveGame']]) + live.receive({ gameLeft: { gameId: 'GAME01' } }) + expect(live.result.current.view).toBeNull() + expect(live.onLeft).toHaveBeenCalledTimes(1) + + const over = mount() + over.receive({ gameState: { view: view({ phase: 'ended' }) } }) + act(() => over.result.current.leaveTable()) + expect(over.move).not.toHaveBeenCalled() + expect(over.result.current.view).toBeNull() + expect(over.onLeft).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useLobby.ts b/src/hooks/useLobby.ts index 8d5d6cb0..b83c4a9d 100644 --- a/src/hooks/useLobby.ts +++ b/src/hooks/useLobby.ts @@ -12,17 +12,20 @@ import type { VoiceDevice } from '@/utils/voiceMesh' import { browserVoiceDevice } from '@/utils/voiceDevice' import type { CastleMoveName, CastleUpdate } from '@/apps/castle/wire' import type { GolfMoveName, GolfUpdate } from '@/apps/golf/wire' +import type { RummyMoveName, RummyUpdate } from '@/apps/rummy/wire' import { useCastleTable } from './useCastleTable' import type { UseCastleTable } from './useCastleTable' import { useGolfTable } from './useGolfTable' import type { UseGolfTable } from './useGolfTable' +import { useRummyTable } from './useRummyTable' +import type { UseRummyTable } from './useRummyTable' // The lobby (MoonBase#1490): the one page for the games hub. One stream // carries the room, its chat, the world, and the tables. The world is // always up — the hub puts this session in its room's world, or the // plaza's — and a table of either game swaps the main view while the // world keeps ticking (MoonBase#1502). A seat is at one table at most, -// so at most one of the two hooks holds a view. +// so at most one of the table hooks holds a view. // // The world follows the hub's room. The hub leaves a world for this // session on every room change and at every close, and refuses a second @@ -77,6 +80,7 @@ export interface UseLobby { voice: VoiceMesh castle: UseCastleTable golf: UseGolfTable + rummy: UseRummyTable } // A share link's room the session is on its way to: left the resumed @@ -174,6 +178,9 @@ export const useLobby = ({ const golfMove = useCallback((name: GolfMoveName, payload: unknown = {}) => { streamRef.current?.move('golf', name, payload) }, []) + const rummyMove = useCallback((name: RummyMoveName, payload: unknown = {}) => { + streamRef.current?.move('rummy', name, payload) + }, []) const onTableLeft = useCallback(() => { if (roomIdRef.current !== null) navigate(lobbyRoomPath(roomIdRef.current), { replace: true }) }, [navigate]) @@ -183,9 +190,13 @@ export const useLobby = ({ const golf = useGolfTable({ playerId, move: golfMove, showNotice, onLeft: onTableLeft }) const golfRef = useRef(golf) golfRef.current = golf + const rummy = useRummyTable({ playerId, move: rummyMove, showNotice, onLeft: onTableLeft }) + const rummyRef = useRef(rummy) + rummyRef.current = rummy const clearTables = useCallback(() => { castleRef.current.clear() golfRef.current.clear() + rummyRef.current.clear() }, []) // The share link's table, once its room is in hand: a table still @@ -209,8 +220,9 @@ export const useLobby = ({ showNotice(`Table ${gameId} is in play`) return } - if (table.game === 'golf') golfRef.current.joinTable(gameId) - else castleRef.current.joinTable(gameId) + if (table.game === 'castle') castleRef.current.joinTable(gameId) + else if (table.game === 'rummy') rummyRef.current.joinTable(gameId) + else golfRef.current.joinTable(gameId) }, [showNotice] ) @@ -307,6 +319,7 @@ export const useLobby = ({ (reason: string) => { // Whatever was refused, nothing a table asked for arrived. castleRef.current.handleRejected() + rummyRef.current.handleRejected() voice.rejected(reason) chatSeqRef.current += 1 setChat(prev => ({ ...prev, rejection: { seq: chatSeqRef.current, reason } })) @@ -336,6 +349,10 @@ export const useLobby = ({ const castleUpdate = update as CastleUpdate castleRef.current.handleUpdate(castleUpdate) joined = castleUpdate.gameJoined?.view.gameId + } else if (game === 'rummy') { + const rummyUpdate = update as RummyUpdate + rummyRef.current.handleUpdate(rummyUpdate) + joined = rummyUpdate.gameJoined?.view.gameId } else if (game === 'golf') { const golfUpdate = update as GolfUpdate golfRef.current.handleUpdate(golfUpdate) @@ -425,6 +442,7 @@ export const useLobby = ({ world, voice, castle, - golf + golf, + rummy } } diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts new file mode 100644 index 00000000..c18bd275 --- /dev/null +++ b/src/hooks/useRummyTable.ts @@ -0,0 +1,208 @@ +import { useCallback, useState } from 'react' +import type { Card, RummyGameEnded, RummyMoveName, RummyMovePayloads, RummyUpdate, RummyView } from '@/apps/rummy/wire' +import type { HandOrder } from '@/apps/rummy/rules' +import { face, seatOf } from '@/apps/rummy/rules' +import { safeLocalStorage } from '@/utils/safeLocalStorage' + +// A rummy table as the wire sends it, plus the viewer's selection and +// hand order on top, over the lobby's stream (useLobby). The owner feeds +// handleUpdate every rummy update and clears the table on a resume. + +// How the viewer likes the hand laid out outlives the table. +const ORDER_KEY = 'rummy.order' + +const storedOrder = (): HandOrder => (safeLocalStorage.get(ORDER_KEY) === 'rank' ? 'rank' : 'suit') + +// What the table's chrome calls; the lobby panel adds create and join. +export interface RummyTableActions { + startTable: () => void + leaveTable: () => void + // Another table, from the one that just ended: a create, since the + // finished one is already gone from the hub. + playAgain: () => void + drawStock: () => void + drawDiscard: () => void + // Selection is by card, not by slot: the hand is shown sorted, and a + // view that lands between two taps can move a card but not rename it. + toggleCard: (card: Card) => void + meldSelected: () => void + // The one selected card, onto the meld at this place on the table. + layOffSelected: (meldIndex: number) => void + discardSelected: () => void + setOrder: (order: HandOrder) => void +} + +export interface UseRummyTable extends RummyTableActions { + createTable: () => void + joinTable: (gameId: string) => void + view: RummyView | null + ended: RummyGameEnded | null + // Faces of the selected cards, in the order they were picked. + selected: string[] + order: HandOrder + // A table has been asked for and not yet arrived. + opening: 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. + handleRejected: () => void + clear: () => void +} + +export interface UseRummyTableProps { + playerId: string + // Typed per move, so a misspelled member is a compile error rather + // than a frame the hub cannot decode. + move: (name: N, payload?: RummyMovePayloads[N]) => void + showNotice: (message: string) => void + // The table is gone from the hub: gameLeft, or a "Back" from an ended + // table. The owner may steer the URL. + onLeft?: () => void +} + +export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTableProps): UseRummyTable => { + const [view, setView] = useState(null) + const [ended, setEnded] = useState(null) + const [selected, setSelected] = useState([]) + const [opening, setOpening] = useState(false) + const [order, setOrderState] = useState(storedOrder) + + const clear = useCallback(() => { + setView(null) + setEnded(null) + setSelected([]) + setOpening(false) + }, []) + const handleRejected = useCallback(() => setOpening(false), []) + + const handleUpdate = useCallback( + (update: RummyUpdate) => { + if (update.gameJoined) { + setView(update.gameJoined.view) + setEnded(null) + setSelected([]) + setOpening(false) + return + } + if (update.gameState) { + setView(update.gameState.view) + setSelected([]) + 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. + return + } + if (update.gameEnded) { + setEnded(update.gameEnded) + return + } + if (update.gameLeft) { + clear() + onLeft?.() + } + }, + [clear, onLeft, playerId, showNotice] + ) + + const createTable = useCallback(() => move('createGame'), [move]) + // One per ending: the hub answers the first with a table and refuses a + // second, which would read as the first having failed. + const playAgain = useCallback(() => { + setOpening(true) + move('createGame') + }, [move]) + const joinTable = useCallback((gameId: string) => move('joinGame', { gameId }), [move]) + const startTable = useCallback(() => move('startGame'), [move]) + const leaveTable = useCallback(() => { + if (view !== null && view.phase !== 'ended') { + move('leaveGame') + return + } + // An ended table is already gone from the hub: only the view lingers. + clear() + onLeft?.() + }, [clear, move, onLeft, view]) + + const drawStock = useCallback(() => move('drawStock'), [move]) + const drawDiscard = useCallback(() => move('drawDiscard'), [move]) + + const toggleCard = useCallback((card: Card) => { + const picked = face(card) + setSelected(prev => (prev.includes(picked) ? prev.filter(f => f !== picked) : [...prev, picked])) + }, []) + + // The selected cards, read out of the hand now on screen. A card that + // has left the hand since it was picked sends nothing — half a meld is + // not the meld anyone chose — and drops the selection with it. + const selectedCards = useCallback((): Card[] | null => { + const hand = view === null ? [] : (seatOf(view, playerId)?.hand ?? []) + const cards = selected.map(picked => hand.find(card => face(card) === picked)).filter(card => card !== undefined) + if (cards.length === 0 || cards.length !== selected.length) { + setSelected([]) + return null + } + return cards + }, [playerId, selected, view]) + + const meldSelected = useCallback(() => { + const cards = selectedCards() + if (cards === null) return + move('meld', { cards }) + setSelected([]) + }, [move, selectedCards]) + + const layOffSelected = useCallback( + (meldIndex: number) => { + const cards = selectedCards() + if (cards === null || cards.length !== 1) return + move('layOff', { card: cards[0], meldIndex }) + setSelected([]) + }, + [move, selectedCards] + ) + + const discardSelected = useCallback(() => { + const cards = selectedCards() + if (cards === null || cards.length !== 1) return + move('discard', { card: cards[0] }) + setSelected([]) + }, [move, selectedCards]) + + const setOrder = useCallback((next: HandOrder) => { + setOrderState(next) + safeLocalStorage.set(ORDER_KEY, next) + }, []) + + return { + view, + ended, + selected, + order, + opening, + handleUpdate, + handleRejected, + clear, + createTable, + joinTable, + startTable, + leaveTable, + playAgain, + drawStock, + drawDiscard, + toggleCard, + meldSelected, + layOffSelected, + discardSelected, + setOrder + } +} diff --git a/src/utils/hubStream.ts b/src/utils/hubStream.ts index 95f346b8..f78fed4d 100644 --- a/src/utils/hubStream.ts +++ b/src/utils/hubStream.ts @@ -37,7 +37,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // --- wire shapes (mirrors model/games.smithy + model/golf.smithy) --- -export type HubGameName = 'golf' | 'castle' +export type HubGameName = 'golf' | 'castle' | 'rummy' // The table a member is at, pending or in play (MoonBase#1490); absent // while idle, which is how the lobby tells who is free. @@ -107,6 +107,7 @@ type HubFrame = | { event: 'commandRejected'; payload: { reason: string } } | { event: 'golf'; payload: { update: Record } } | { event: 'castle'; payload: { update: Record } } + | { event: 'rummy'; payload: { update: Record } } | { event: 'lobby'; payload: { update: LobbyUpdate } } | { event: 'voice'; payload: { update: VoiceUpdate } } | { event?: undefined; exception: string; payload: { message?: string } } @@ -282,6 +283,7 @@ export class HubStream { return case 'golf': case 'castle': + case 'rummy': this.callbacks.onGame?.(frame.event, frame.payload.update) return case 'lobby': From be5ded86d106be449c71864b0d80948c7c330dc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:08:24 +0000 Subject: [PATCH 2/8] rummy: readable melds, lay-off hints, focus, labels --- .../rummy/components/RummyTable.module.css | 63 +++++++++++++--- src/apps/rummy/components/RummyTable.tsx | 71 ++++++++++++++----- .../components/__tests__/RummyTable.test.tsx | 61 ++++++++++++++-- src/hooks/__tests__/useRummyTable.test.tsx | 6 ++ src/hooks/useRummyTable.ts | 7 +- 5 files changed, 171 insertions(+), 37 deletions(-) diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css index 62db72c5..2c909d9c 100644 --- a/src/apps/rummy/components/RummyTable.module.css +++ b/src/apps/rummy/components/RummyTable.module.css @@ -5,7 +5,7 @@ /* The middle is wider than castle's pile: the melds wrap beneath the piles, and a table of them wants the room. */ .middle { - max-width: min(26rem, 90%); + max-width: min(36rem, 90%); gap: 0.6rem; } @@ -16,7 +16,7 @@ gap: 0.4rem 0.6rem; /* A long game's melds scroll inside the middle rather than pushing the chairs off the felt. */ - max-height: 11rem; + max-height: 16rem; overflow-y: auto; } @@ -24,17 +24,33 @@ font-size: 0.8rem; } -/* A meld is its cards overlapped tight, drawn smaller than a hand: it is - read, not held. */ +/* A meld is its cards overlapped at full size, each showing its corner + index — the melds are the shared board, read by every chair — with who + laid it underneath. */ .meld { display: inline-flex; - padding: 0.2rem 0.3rem; - border-radius: 0.4rem; + flex-direction: column; + align-items: center; + gap: 0.15rem; + padding: 0.25rem 0.35rem; + border-radius: 0.5rem; border: 2px solid transparent; background: none; font: inherit; color: inherit; - zoom: 0.72; +} + +.meldCards { + display: flex; +} + +.meldOwner { + max-width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.65); } button.meld { @@ -44,7 +60,8 @@ button.meld { .meldSlot { position: relative; display: block; - margin-left: -1.9rem; + /* What is left of each card under the next: its index, "10" included. */ + margin-left: -1.75rem; } .meldSlot:first-child { @@ -53,6 +70,7 @@ button.meld { /* A meld the picked card would grow: lit, the place to tap. */ .fits { + border-width: 3px; border-color: #f0c96a; background: rgba(240, 201, 106, 0.15); animation: fits 1.2s ease-in-out infinite alternate; @@ -85,6 +103,21 @@ button.meld { animation: fits 1.2s ease-in-out infinite alternate; } +/* A hand card that would grow a meld on the table: a gold dot, before it + is picked. */ +.laysOff::after { + content: ''; + position: absolute; + top: -0.3rem; + left: 50%; + width: 0.5rem; + height: 0.5rem; + margin-left: -0.25rem; + border-radius: 50%; + background: #f0c96a; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); +} + /* The card just taken from the discard: it can't go straight back. */ .taken { outline: 2px dashed #f0c96a; @@ -156,7 +189,12 @@ button.meld { } .melds { - max-height: 8rem; + max-height: 10rem; + } + + /* Smaller cards (2.6rem): the same index left showing. */ + .meldSlot { + margin-left: -1.35rem; } } @@ -172,3 +210,10 @@ button.meld { animation: none; } } + +@media (max-height: 700px) and (min-width: 601px) { + /* The short window's cards (2.25rem). */ + .meldSlot { + margin-left: -1.05rem; + } +} diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index 04a56204..b089b7b6 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -92,6 +92,15 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr const fitting = single === null ? [] : meldsFitting(view.melds, single) const discardable = single !== null && canDiscard(view, myHand, single) + // A move takes the button that made it away — the picked card, the lit + // meld, the draw that becomes a disabled Meld — so focus goes to the hand, + // which is where the next move is picked. + const handRef = useRef(null) + const thenHand = (move: () => void) => () => { + move() + handRef.current?.focus() + } + const showEnding = view.phase === 'ended' && ended !== null && endingRead !== view.gameId const keepFocusIn = (event: KeyboardEvent) => { if (event.key !== 'Tab') return @@ -125,9 +134,14 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr } if (laying) { if (single !== null && fitting.length > 0) return `Tap a lit meld to lay off ${face(single)}, or discard it.` + if (picked.length === 2) return 'Pick three or more to meld, or one to lay off or discard.' if (picked.length >= 3 && meld === null) return 'Those cards are not a set or a run.' + if (view.takenDiscard !== undefined) return `Meld or lay off if you can, then discard — not the ${face(view.takenDiscard)} you just took.` return 'Meld or lay off if you can, then discard to end your turn.' } + if (view.phase === 'playing' && view.currentPlayerId !== undefined) { + return `Waiting for ${view.currentPlayerId} to ${view.stage === 'draw' ? 'draw' : 'play'}.` + } return '' })() @@ -164,10 +178,10 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr if (drawing) { return ( <> - - @@ -176,13 +190,13 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr if (laying) { return ( <> - - - {single !== null && !canDiscard(view, myHand, single) && ( + {single !== null && !discardable && (

You just took {face(single)}: it can’t go straight back.

)} @@ -226,12 +240,14 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr {!mine && ( SHOWN_BACKS ? felt.handCountShown : ''}`}> · {seat.handCount} in hand )} - {view.phase === 'ended' && · {deadwood(seat.hand)} left} + {view.phase === 'ended' && · {deadwood(seat.hand)} pts left}
0 + const notes = [taken ? 'just taken' : '', laysOff ? 'fits a meld' : ''].filter(Boolean) return ( 0 ? [face(card), ...notes].join(', ') : undefined} toggle={mine && laying ? selected.includes(face(card)) : undefined} onClick={mine && laying && connected ? () => table.toggleCard(card) : undefined} /> @@ -268,7 +288,9 @@ const RummyTable = ({ playerId, connected, view, table, children }: RummyTablePr
{mine && view.phase !== 'waiting' && (
- Deadwood {deadwood(myHand)} + + {deadwood(myHand)} pts in hand + {(['suit', 'rank'] as const).map(by => ( -
- -
+ ) : ( + 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' && view.dealNumber > 0 && (
diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 72bb8e41..c6d3b325 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,28 @@ 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, startTable: vi.fn(), + chooseVariant: vi.fn(), leaveTable: vi.fn(), playAgain: vi.fn(), drawStock: vi.fn(), @@ -52,9 +74,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 +85,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 +263,87 @@ 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('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..01c2d088 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,27 @@ 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', () => { + const { result, move } = mount() + act(() => result.current.chooseVariant('basic')) + expect(move.mock.calls).toEqual([['chooseVariant', { variant: 'basic' }]]) }) 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..18f71206 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. @@ -95,13 +97,10 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa 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 +124,7 @@ 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) => move('chooseVariant', { variant }), [move]) const leaveTable = useCallback(() => { if (view !== null && view.phase !== 'ended') { move('leaveGame') @@ -194,6 +194,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa createTable, joinTable, startTable, + chooseVariant, leaveTable, playAgain, drawStock, From 6240cca039c60ef12cc64f0c546c497efab5ea5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:04:10 +0000 Subject: [PATCH 4/8] rummy: before the first deal, seats show no cards --- src/apps/rummy/components/RummyTable.tsx | 12 +++++++----- .../rummy/components/__tests__/RummyTable.test.tsx | 7 +++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index e92338a9..b7bf6431 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -84,6 +84,8 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R const mayDeal = canDeal(view, playerId, away) // Once a deal is over every hand is face up, and stays so between deals. const handsShown = view.lastDeal !== undefined && view.phase !== 'playing' + // Before the first deal a seat is a name: there are no cards to count. + const dealt = view.dealNumber > 0 const wonBy = (id: string) => view.standings.find(standing => standing.playerId === id)?.handsWon ?? 0 // Cards that just entered the viewer's hand slide in, so a draw reads @@ -285,10 +287,10 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

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

@@ -335,7 +337,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
- {mine && view.phase !== 'waiting' && ( + {mine && dealt && (
{deadwood(myHand)} pts in hand @@ -360,7 +362,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

Rummy · {view.gameId}

- {view.dealNumber > 0 && view.variant !== undefined && ( + {dealt && view.variant !== undefined && (

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

@@ -463,7 +465,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R )}
{fromViewer(view.players, playerId).map((seat, i, all) => renderSeat(seat, clockOf(all.length, i)))} - {view.phase !== 'waiting' && view.dealNumber > 0 && ( + {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 c6d3b325..2686fc23 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -312,6 +312,13 @@ describe('RummyTable', () => { 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 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() From 959075089a00593f0b7eb7ba332a0a38aeb89bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:09:53 +0000 Subject: [PATCH 5/8] rummy: deal-sheet focus, showdown on small screens, single deal --- .../castle/components/CastleTable.module.css | 7 ++-- src/apps/lobby/components/LobbyPanel.tsx | 2 +- .../components/__tests__/LobbyPanel.test.tsx | 5 +++ src/apps/rummy/components/RummyTable.tsx | 36 ++++++++++++----- .../components/__tests__/RummyTable.test.tsx | 40 +++++++++++++++++++ src/hooks/__tests__/useRummyTable.test.tsx | 10 ++++- src/hooks/useRummyTable.ts | 20 +++++++++- 7 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/apps/castle/components/CastleTable.module.css b/src/apps/castle/components/CastleTable.module.css index d1e3c858..3be85e38 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; } 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} )) @@ -357,7 +370,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R } return ( -
    +

    Rummy · {view.gameId} @@ -377,7 +390,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

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

    @@ -389,6 +402,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R role="dialog" aria-modal="true" aria-labelledby="rummy-ending" + aria-describedby={showDealEnd ? 'rummy-deal-result rummy-deal-next' : 'rummy-table-result'} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault() @@ -403,7 +417,9 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

    {headlineOf(lastDeal, playerId)}

    -

    {describeEnding(lastDeal, playerId)}

    +

    + {describeEnding(lastDeal, playerId)} +

    {lastDeal.scores.length > 0 && ( @@ -417,7 +433,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
    Points left in hand
    )} -

    +

    {!mayDeal ? `${dealer} deals next.` : dealer === playerId ? 'Your deal next.' : `${dealer} is away: you can deal.`}

    {mayDeal &&
    {dealButtons(true)}
    } @@ -432,7 +448,9 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

    The table closed

    -

    {describeTableEnd(ended, playerId)}

    +

    + {describeTableEnd(ended, playerId)} +

    {ended.standings.length > 0 && ( diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 2686fc23..57676c7a 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -60,6 +60,7 @@ const table = (over: Partial = {}): RummyTableProps['t selected: [], order: 'suit', opening: false, + dealing: false, startTable: vi.fn(), chooseVariant: vi.fn(), leaveTable: vi.fn(), @@ -319,6 +320,45 @@ describe('RummyTable', () => { 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('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() diff --git a/src/hooks/__tests__/useRummyTable.test.tsx b/src/hooks/__tests__/useRummyTable.test.tsx index 01c2d088..80c79f7e 100644 --- a/src/hooks/__tests__/useRummyTable.test.tsx +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -151,10 +151,16 @@ describe('useRummyTable', () => { expect(showNotice.mock.calls).toEqual([['bob opened table G2']]) }) - it('the dealer’s pick names the variant', () => { - const { result, move } = mount() + 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) }) it('play again opens another table; the ending goes with the old one', () => { diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts index 18f71206..92d6cff5 100644 --- a/src/hooks/useRummyTable.ts +++ b/src/hooks/useRummyTable.ts @@ -46,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. @@ -69,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(() => { @@ -76,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) => { @@ -91,6 +99,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa if (update.gameState) { setView(update.gameState.view) setSelected([]) + setDealing(false) return } if (update.gameCreated) { @@ -124,7 +133,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) => move('chooseVariant', { variant }), [move]) + const chooseVariant = useCallback( + (variant: string) => { + setDealing(true) + move('chooseVariant', { variant }) + }, + [move] + ) const leaveTable = useCallback(() => { if (view !== null && view.phase !== 'ended') { move('leaveGame') @@ -188,6 +203,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa selected, order, opening, + dealing, handleUpdate, handleRejected, clear, From a05f3ff4cd59ce143a5e99153956786c142b2f92 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:16:43 +0000 Subject: [PATCH 6/8] rummy: hand width comment says seven --- src/apps/rummy/components/RummyTable.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css index 2c909d9c..1a0c6ac3 100644 --- a/src/apps/rummy/components/RummyTable.module.css +++ b/src/apps/rummy/components/RummyTable.module.css @@ -124,7 +124,7 @@ button.meld { outline-offset: -4px; } -/* A hand of ten or eleven is wider than castle's three. */ +/* A hand of seven or eight is wider than castle's three. */ .hand { --overlap-max: 1.7rem; } From 3de344e75a53247ec02c2bbf2cca48d3f5ebf544 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:18:30 +0000 Subject: [PATCH 7/8] felt: the ending card sets its own text colour --- src/apps/castle/components/CastleTable.module.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apps/castle/components/CastleTable.module.css b/src/apps/castle/components/CastleTable.module.css index 3be85e38..458b4bf3 100644 --- a/src/apps/castle/components/CastleTable.module.css +++ b/src/apps/castle/components/CastleTable.module.css @@ -940,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; From c3fe69ad23f3c05d3fb64bc587fd36e5a88cfc12 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:46:08 +0000 Subject: [PATCH 8/8] rummy: review nits: deal focus, face-up counts, join clears dealing --- src/apps/lobby/components/LobbyGame.tsx | 4 ++-- src/apps/rummy/components/RummyTable.tsx | 14 +++++++++----- .../components/__tests__/RummyTable.test.tsx | 16 ++++++++++++++++ src/hooks/__tests__/useRummyTable.test.tsx | 4 ++++ src/hooks/useRummyTable.ts | 1 + 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/apps/lobby/components/LobbyGame.tsx b/src/apps/lobby/components/LobbyGame.tsx index b015a50f..91d393c6 100644 --- a/src/apps/lobby/components/LobbyGame.tsx +++ b/src/apps/lobby/components/LobbyGame.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import ThoughtsGame from '@/apps/thoughts/components/ThoughtsGame' import CastleTable from '@/apps/castle/components/CastleTable' import GolfTable from '@/apps/golf/components/GolfTable' @@ -32,7 +32,7 @@ const LobbyGame = (props: UseLobbyProps) => { 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 = useMemo(() => (room?.players ?? []).filter(player => !player.connected).map(player => player.playerId), [room]) + 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) diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index 586f3ce6..8d7f250a 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -147,11 +147,15 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R useEffect(() => { if (showEnding || showDealEnd) playAgainRef.current?.focus() }, [showEnding, showDealEnd]) - // A sheet whose focused button went — the away dealer came back — keeps - // focus inside it, where Escape and Tab are handled. + // The sheet follows the dealer's presence: a deal it comes to offer — the + // dealer went away — takes focus; a button it loses — they came back — + // leaves focus inside it, where Escape and Tab are handled. useEffect(() => { - if (!showDealEnd || endingRef.current === null || endingRef.current.contains(document.activeElement)) return - endingRef.current.querySelector('button:not(:disabled)')?.focus() + if (!showDealEnd || endingRef.current === null) return + if (mayDeal) playAgainRef.current?.focus() + else if (!endingRef.current.contains(document.activeElement)) { + endingRef.current.querySelector('button:not(:disabled)')?.focus() + } }, [showDealEnd, mayDeal]) // A deal arriving takes away whatever dealt it — the sheet, the buttons — // so focus goes to the hand, where the deal is played. @@ -300,7 +304,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

    {label} {onTurn && · {view.stage === 'draw' ? 'to draw' : 'to play'}} - {!mine && dealt && ( + {!mine && dealt && !handsShown && ( SHOWN_BACKS ? felt.handCountShown : ''}`}> · {seat.handCount} in hand )} {dealt && · {wonBy(seat.playerId)} won} diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 57676c7a..2b9f1c4c 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -350,6 +350,22 @@ describe('RummyTable', () => { 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 }) diff --git a/src/hooks/__tests__/useRummyTable.test.tsx b/src/hooks/__tests__/useRummyTable.test.tsx index 80c79f7e..2816aa0d 100644 --- a/src/hooks/__tests__/useRummyTable.test.tsx +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -161,6 +161,10 @@ describe('useRummyTable', () => { 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', () => { diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts index 92d6cff5..e8f26c7b 100644 --- a/src/hooks/useRummyTable.ts +++ b/src/hooks/useRummyTable.ts @@ -94,6 +94,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa setEnded(null) setSelected([]) setOpening(false) + setDealing(false) return } if (update.gameState) {

    Hands won