diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9e2bdbba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# muchq.github.io + +**[`WORKING_AGREEMENT.md`](WORKING_AGREEMENT.md)** is the governing process +document: how work gets picked up, reviewed, verified, and shipped here. + +- **Never schedule check-ins on a PR** — hourly or otherwise. Subscribe to its + activity and end the turn; events wake the session. diff --git a/WORKING_AGREEMENT.md b/WORKING_AGREEMENT.md index 5a0dd96b..8f37c37f 100644 --- a/WORKING_AGREEMENT.md +++ b/WORKING_AGREEMENT.md @@ -46,6 +46,11 @@ discovery before the code exists than after. the PR only on request. Reference the tracking issue and, when the issue is a checklist, tick the item once merged. +**No scheduled check-ins on a PR.** Never schedule an hourly check-in — or +any timed one — to poll a PR's CI, mergeability or review threads. Subscribe +to the PR's activity and end the turn: its events wake the session, and a PR +with nothing happening on it waits for its owner, not for a timer. + **Update the tracking issue.** Fold new data — reproductions, measurements, scope corrections — back into the issue so it stays the source of truth. File follow-ups for what you deliberately left out rather than leaving it implicit. diff --git a/src/apps/rummy/__tests__/arrange_corpus.json b/src/apps/rummy/__tests__/arrange_corpus.json new file mode 100644 index 00000000..88699e46 --- /dev/null +++ b/src/apps/rummy/__tests__/arrange_corpus.json @@ -0,0 +1,165 @@ +{ + "arrange": [ + { "hand": ["A♥", "K♥", "Q♥", "J♥", "10♥", "A♣", "K♣", "Q♣", "J♣", "10♣"], "deadwood": 0 }, + { "hand": ["K♠", "A♠", "2♠", "3♦", "4♦", "5♦", "9♣", "9♦", "9♥", "J♠"], "deadwood": 23 }, + { "hand": ["A♠", "2♠", "3♠", "4♠", "5♠", "7♣", "7♦", "7♥", "7♠", "K♦"], "deadwood": 10 }, + { "hand": ["7♥", "7♣", "7♦", "5♥", "6♥", "8♥", "9♥", "2♠", "3♠", "K♣"], "deadwood": 29 }, + { "hand": ["4♣", "5♣", "6♣", "7♣", "4♦", "4♥", "6♦", "6♥", "Q♠", "K♠"], "deadwood": 32 }, + { "hand": ["2♣", "2♦", "2♥", "2♠", "3♣", "3♦", "3♥", "4♣", "4♦", "4♥"], "deadwood": 0 }, + { "hand": ["A♣", "2♣", "3♣", "Q♦", "K♦", "A♦", "5♥", "6♥", "8♥", "9♥"], "deadwood": 28 }, + { "hand": ["J♠", "Q♠", "K♠", "10♦", "J♦", "Q♦", "10♥", "J♥", "Q♥", "10♣"], "deadwood": 10 }, + { "hand": ["10♠", "J♠", "Q♠", "K♠", "A♠", "9♠", "8♠", "7♠", "6♠", "5♠", "4♠"], "deadwood": 0 }, + { "hand": ["K♣", "Q♦", "J♥", "10♠", "9♣", "8♦", "7♥", "6♠", "5♣", "4♦"], "deadwood": 79 }, + { "hand": ["7♣", "7♦", "7♥", "7♠", "8♠", "9♠", "2♦", "3♣", "4♥", "K♦"], "deadwood": 19 }, + { "hand": ["6♦", "2♠", "K♦", "9♣", "9♦", "9♥", "3♠", "4♣", "4♥", "2♣"], "deadwood": 31 }, + { "hand": ["Q♣", "8♦", "J♥", "J♠", "5♦", "5♥", "6♦", "7♠", "8♠", "2♦"], "deadwood": 71 }, + { "hand": ["4♣", "5♠", "J♦", "6♠", "A♥", "10♠", "6♦", "5♦", "K♦", "K♠"], "deadwood": 67 }, + { "hand": ["Q♥", "7♠", "J♠", "7♣", "3♥", "10♥", "9♥", "K♦", "A♦", "8♣"], "deadwood": 75 }, + { "hand": ["J♣", "6♥", "A♥", "Q♥", "9♣", "5♦", "2♣", "A♠", "4♣", "Q♣"], "deadwood": 58 }, + { "hand": ["8♥", "10♦", "5♦", "4♥", "J♣", "2♣", "4♣", "8♣", "7♣", "A♦"], "deadwood": 59 }, + { "hand": ["A♠", "Q♦", "6♥", "3♠", "A♦", "4♠", "8♠", "J♠", "K♦", "J♥"], "deadwood": 42 }, + { "hand": ["3♥", "8♥", "2♦", "J♠", "3♠", "K♦", "4♥", "7♣", "10♦", "8♣"], "deadwood": 65 }, + { "hand": ["5♣", "Q♣", "9♥", "K♣", "7♥", "9♦", "8♥", "2♣", "6♥", "2♠"], "deadwood": 38 }, + { "hand": ["4♦", "5♠", "Q♠", "3♣", "4♠", "K♣", "10♣", "J♥", "2♣", "5♥"], "deadwood": 63 }, + { "hand": ["10♦", "A♦", "2♥", "J♥", "K♣", "2♦", "7♣", "9♠", "8♥", "8♦"], "deadwood": 67 }, + { "hand": ["6♣", "Q♣", "J♠", "2♦", "2♣", "10♥", "Q♥", "2♠", "Q♦", "9♠"], "deadwood": 35 }, + { "hand": ["K♥", "3♣", "4♠", "J♥", "8♥", "3♠", "4♦", "9♦", "4♥", "Q♦"], "deadwood": 53 }, + { "hand": ["K♥", "9♥", "Q♠", "4♥", "7♠", "9♠", "Q♣", "3♦", "J♥", "10♥"], "deadwood": 53 }, + { "hand": ["10♦", "2♠", "4♥", "A♣", "Q♥", "K♠", "K♣", "10♥", "10♣", "2♦"], "deadwood": 39 }, + { "hand": ["A♠", "3♦", "3♣", "8♠", "J♦", "J♠", "2♥", "Q♣", "6♠", "7♦"], "deadwood": 60 }, + { "hand": ["8♥", "A♠", "6♦", "9♣", "A♥", "3♠", "4♥", "6♥", "3♥", "A♣"], "deadwood": 39 }, + { "hand": ["3♥", "2♦", "6♦", "8♣", "K♠", "7♦", "K♥", "2♣", "10♦", "9♦"], "deadwood": 67 }, + { "hand": ["9♦", "10♠", "Q♣", "3♣", "3♠", "K♦", "4♥", "10♦", "10♣", "K♥", "A♥"], "deadwood": 50 }, + { "hand": ["2♥", "K♥", "5♠", "K♦", "8♥", "K♠", "2♠", "7♣", "Q♥", "7♥", "3♥"], "deadwood": 44 }, + { "hand": ["2♦", "4♠", "K♥", "9♣", "A♣", "Q♠", "5♠", "J♦", "A♠", "9♥", "6♦"], "deadwood": 67 }, + { "hand": ["5♠", "3♥", "5♦", "3♣", "Q♠", "6♦", "Q♦", "4♣", "8♥", "6♣", "6♥"], "deadwood": 48 }, + { "hand": ["7♥", "5♠", "7♠", "7♣", "10♠", "2♣", "Q♥", "9♠", "8♦", "K♠", "5♥"], "deadwood": 59 }, + { "hand": ["J♣", "2♠", "5♣", "7♦", "A♥", "9♦", "K♥", "J♠", "7♣", "8♥", "J♦"], "deadwood": 49 }, + { "hand": ["A♣", "2♣", "J♠", "5♦", "4♥", "9♠", "6♥", "3♦", "7♠", "Q♦", "2♠"], "deadwood": 59 }, + { "hand": ["5♥", "5♦", "J♠", "7♥", "9♥", "2♥", "A♦", "J♥", "3♠", "6♦", "J♦"], "deadwood": 38 }, + { "hand": ["K♠", "Q♥", "2♦", "7♥", "4♣", "5♦", "10♦", "6♥", "5♥", "9♣", "8♦"], "deadwood": 58 }, + { "hand": ["K♥", "10♦", "2♣", "8♠", "3♠", "9♦", "J♥", "8♦", "9♣", "3♣", "6♥"], "deadwood": 51 }, + { "hand": ["Q♠", "Q♦", "A♣", "10♦", "3♣", "6♦", "K♣", "5♣", "8♦", "4♠", "J♠"], "deadwood": 77 }, + { "hand": ["2♥", "J♥", "8♠", "5♦", "7♦", "8♦", "Q♣", "6♠", "J♠", "5♠", "4♦"], "deadwood": 75 } + ], + "layOff": [ + { "hand": ["4♥", "3♥", "8♥", "K♣", "K♦", "2♣"], "onto": [["5♥", "6♥", "7♥"]], "deadwood": 22 }, + { "hand": ["9♣", "9♦", "9♥", "2♠", "3♠", "Q♦"], "onto": [["9♠", "10♠", "J♠"]], "deadwood": 15 }, + { "hand": ["5♥", "5♣", "5♦", "4♥", "K♠", "2♦"], "onto": [["6♥", "7♥", "8♥"]], "deadwood": 16 }, + { "hand": ["A♠", "2♠", "K♥", "Q♣"], "onto": [["3♠", "4♠", "5♠"]], "deadwood": 20 }, + { "hand": ["J♦", "Q♦", "K♦", "A♦", "3♣"], "onto": [["8♦", "9♦", "10♦"]], "deadwood": 3 }, + { "hand": ["7♣", "3♠", "4♠", "Q♥"], "onto": [["7♥", "7♠", "7♦"]], "deadwood": 17 }, + { + "hand": ["8♦", "10♥", "2♣", "3♦", "10♦", "6♣", "10♠", "9♠", "8♥"], + "onto": [ + ["9♣", "10♣", "J♣"], + ["5♣", "5♦", "5♥"] + ], + "deadwood": 36 + }, + { + "hand": ["4♦", "4♣", "K♦", "9♠", "7♦", "A♦", "2♦", "Q♠", "7♠", "J♦"], + "onto": [ + ["4♠", "5♠", "6♠"], + ["3♣", "3♦", "3♥"] + ], + "deadwood": 57 + }, + { + "hand": ["5♠", "J♥", "3♦", "A♣", "2♦", "A♥", "4♥", "2♠", "6♠"], + "onto": [ + ["10♦", "J♦", "Q♦"], + ["8♣", "8♦", "8♥"] + ], + "deadwood": 34 + }, + { + "hand": ["Q♣", "10♦", "9♥", "9♦", "A♥", "J♦", "6♠", "K♥", "7♠"], + "onto": [ + ["3♦", "4♦", "5♦"], + ["7♣", "7♦", "7♥"] + ], + "deadwood": 36 + }, + { + "hand": ["Q♠", "6♦", "10♥", "K♣", "3♥", "A♦", "A♠"], + "onto": [ + ["6♣", "7♣", "8♣"], + ["2♣", "2♦", "2♥"] + ], + "deadwood": 41 + }, + { + "hand": ["3♣", "2♦", "8♥", "K♠", "10♦", "K♦", "3♦", "5♦", "K♣"], + "onto": [ + ["3♠", "4♠", "5♠"], + ["7♣", "7♦", "7♥"] + ], + "deadwood": 31 + }, + { + "hand": ["4♥", "5♠", "K♣", "9♥", "J♦", "8♥", "A♥"], + "onto": [ + ["9♣", "10♣", "J♣"], + ["7♣", "7♦", "7♥"] + ], + "deadwood": 47 + }, + { + "hand": ["2♠", "6♠", "3♦", "2♦", "7♥", "4♥", "6♣", "A♣", "J♥"], + "onto": [ + ["9♣", "10♣", "J♣"], + ["8♣", "8♦", "8♥"] + ], + "deadwood": 41 + }, + { + "hand": ["6♦", "Q♠", "9♣", "6♥", "J♦", "K♣", "2♠", "A♥", "J♥"], + "onto": [ + ["8♠", "9♠", "10♠"], + ["5♣", "5♦", "5♥"] + ], + "deadwood": 64 + }, + { + "hand": ["3♠", "Q♣", "9♥", "J♠", "A♥", "5♦", "A♠", "J♥", "4♥"], + "onto": [ + ["8♠", "9♠", "10♠"], + ["2♣", "2♦", "2♥"] + ], + "deadwood": 43 + }, + { + "hand": ["J♣", "Q♣", "J♦", "2♣", "J♥", "K♥", "5♠"], + "onto": [ + ["9♠", "10♠", "J♠"], + ["3♣", "3♦", "3♥"] + ], + "deadwood": 27 + }, + { + "hand": ["A♠", "2♥", "7♥", "Q♥", "10♥", "9♦", "3♦", "5♦", "9♥"], + "onto": [ + ["7♣", "8♣", "9♣"], + ["4♣", "4♦", "4♥"] + ], + "deadwood": 56 + }, + { + "hand": ["A♣", "2♦", "3♥", "6♦", "5♣", "6♥", "K♣"], + "onto": [ + ["9♥", "10♥", "J♥"], + ["4♣", "4♦", "4♥"] + ], + "deadwood": 33 + }, + { + "hand": ["2♥", "9♦", "K♠", "9♣", "Q♥", "6♠", "J♠", "6♣", "7♥"], + "onto": [ + ["9♥", "10♥", "J♥"], + ["3♣", "3♦", "3♥"] + ], + "deadwood": 59 + } + ] +} diff --git a/src/apps/rummy/__tests__/rules.test.ts b/src/apps/rummy/__tests__/rules.test.ts index 5cd17a80..b0a910e2 100644 --- a/src/apps/rummy/__tests__/rules.test.ts +++ b/src/apps/rummy/__tests__/rules.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { arrangedMeld, + bestArrangement, canDeal, canDiscard, deadwood, @@ -8,11 +9,13 @@ import { describeLastMove, describeTableEnd, headlineOf, + knockable, meldsFitting, sortHand, variantLabel } from '../rules' import type { Card, RummyView } from '../wire' +import corpus from './arrange_corpus.json' // 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 @@ -98,6 +101,7 @@ describe('canDiscard', () => { players: [], stockCount: 0, canDrawStock: true, + canDrawDiscard: true, discardCount: 0, melds: [], ...over @@ -120,11 +124,13 @@ describe('describeLastMove', () => { ) 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♣') + expect(describeLastMove({ playerId: 'bob', move: 'pass', cards: [] }, 'alice')).toBe('bob passed on the upcard') + expect(describeLastMove({ playerId: 'alice', move: 'knock', cards: cards('K♣') }, 'alice')).toBe('You knocked on K♣') }) }) describe('a deal’s end', () => { - const won = { variant: 'basic', winner: 'alice', points: 42, scores: [] } + const won = { variant: '7-card', winner: 'alice', points: 42, scores: [] } it('reads from each chair', () => { expect(headlineOf(won, 'alice')).toBe('You won the hand!') expect(headlineOf(won, 'bob')).toBe('alice wins the hand') @@ -133,7 +139,7 @@ describe('a deal’s end', () => { expect(describeEnding({ ...won, points: 1 }, 'bob')).toBe('alice went out and scores 1 point.') }) it('names nobody for a deal that broke up', () => { - const broke = { variant: 'basic', points: 0, scores: [] } + const broke = { variant: '7-card', points: 0, scores: [] } expect(headlineOf(broke, 'alice')).toBe('The deal broke up') expect(describeEnding(broke, 'alice')).toBe('Nobody went out.') }) @@ -161,11 +167,12 @@ describe('the dealer’s choice', () => { players: [], stockCount: 0, canDrawStock: false, + canDrawDiscard: false, discardCount: 0, melds: [], dealNumber: 1, standings: [], - choosing: { dealer, options: ['basic'] } + choosing: { dealer, options: ['7-card'] } }) it('is the dealer’s alone while they are here', () => { expect(canDeal(choosing('alice'), 'alice', [])).toBe(true) @@ -179,7 +186,90 @@ describe('the dealer’s choice', () => { expect(canDeal({ ...choosing('alice'), phase: 'playing', choosing: undefined }, 'alice', [])).toBe(false) }) it('names each variant, and an unknown one as it came', () => { - expect(variantLabel('basic')).toBe('Basic rummy') - expect(variantLabel('gin')).toBe('gin') + expect(variantLabel('7-card')).toBe('7-card rummy') + expect(variantLabel('10-card')).toBe('10-card rummy') + expect(variantLabel('gin')).toBe('Gin rummy') + expect(variantLabel('canasta')).toBe('canasta') + // A hub from before 7-card had its name still offers `basic`. + expect(variantLabel('basic')).toBe('7-card rummy') + }) +}) + +// The hub arranges gin hands; the UI arranges the viewer's own to say +// what a knock would leave. The corpus is MoonBase's +// libs/cards/rummy/testdata/arrange_corpus.json, copied: both searches +// replay the same cases, which an independent brute force generated. +describe('bestArrangement', () => { + it.each(corpus.arrange.map(({ hand, deadwood }) => [hand.join(' '), hand, deadwood] as const))('%s', (_, hand, expected) => { + const arranged = bestArrangement(cards(...hand)) + expect(arranged.points).toBe(expected) + expect(deadwood(arranged.deadwood)).toBe(expected) + for (const meld of arranged.melds) expect(arrangedMeld(meld)).not.toBeNull() + const used = [...arranged.melds.flat(), ...arranged.deadwood].map(card => card.rank + card.suit) + expect(used.sort()).toEqual([...hand].sort()) + }) +}) + +describe('knockable', () => { + it('is throwing a card that leaves ten or less', () => { + // A-2-3♠ and 7-8-9♥ melded: K♣ and 9♦ left over. + const hand = cards('A♠', '2♠', '3♠', '7♥', '8♥', '9♥', 'K♣', '9♦', 'A♦') + expect(knockable(hand, c('K♣'))).toBe(true) + expect(knockable(hand, c('9♦'))).toBe(false) + expect(knockable(hand, c('Q♥'))).toBe(false) + }) +}) + +describe('the ace, in every variant', () => { + const melds = [ + { owner: 'bob', cards: cards('J♣', 'Q♣', 'K♣') }, + { owner: 'bob', cards: cards('2♥', '3♥', '4♥') }, + { owner: 'bob', cards: cards('Q♠', 'K♠', 'A♠') }, + { owner: 'bob', cards: cards('A♦', '2♦', '3♦') } + ] + it('lays off high over the king or low under the two, never round the corner', () => { + expect(meldsFitting(melds, c('A♣'))).toEqual([0]) + expect(meldsFitting(melds, c('A♥'))).toEqual([1]) + expect(meldsFitting(melds, c('2♠'))).toEqual([]) + expect(meldsFitting(melds, c('K♦'))).toEqual([]) + }) + it('counts toward a gin knock at either end, never round the corner', () => { + const sets = ['7♣', '7♦', '7♥', '3♥', '4♥', '5♥', '2♦', 'K♦'] + expect(knockable(cards('Q♠', 'K♠', 'A♠', ...sets), c('K♦'))).toBe(true) + expect(knockable(cards('A♠', '2♠', '3♠', ...sets), c('K♦'))).toBe(true) + expect(knockable(cards('K♠', 'A♠', '2♠', ...sets), c('K♦'))).toBe(false) + }) +}) + +describe('sortHand by melds', () => { + it('lays each meld together, then the deadwood by suit', () => { + const hand = cards('K♣', '9♥', 'A♠', '8♥', '2♠', '7♥', '3♠', '4♦') + expect(faces(sortHand(hand, 'melds'))).toEqual(['A♠', '2♠', '3♠', '7♥', '8♥', '9♥', 'K♣', '4♦']) + }) +}) + +describe('a gin deal’s end', () => { + const hands = [ + { playerId: 'alice', melds: [], deadwood: [] }, + { playerId: 'bob', melds: [], deadwood: [] } + ] + const ending = (kind: 'knock' | 'gin' | 'undercut' | 'draw', winner?: string) => ({ + variant: 'gin', + winner, + points: 12, + scores: [], + gin: { ending: kind, knocker: kind === 'draw' ? undefined : 'alice', hands, laidOff: [] } + }) + it('says how it ended, from each chair', () => { + expect(describeEnding(ending('knock', 'alice'), 'alice')).toBe('You knocked and score 12 points.') + expect(describeEnding(ending('knock', 'alice'), 'bob')).toBe('alice knocked and scores 12 points.') + expect(describeEnding(ending('gin', 'alice'), 'alice')).toBe('You went gin and score 12 points.') + expect(describeEnding(ending('gin', 'alice'), 'bob')).toBe('alice went gin and scores 12 points.') + expect(describeEnding(ending('undercut', 'bob'), 'bob')).toBe('You undercut alice and score 12 points.') + expect(describeEnding(ending('undercut', 'bob'), 'alice')).toBe('bob undercut you and scores 12 points.') + expect(describeEnding(ending('undercut', 'bob'), 'carol')).toBe('bob undercut alice and scores 12 points.') + expect(describeEnding(ending('draw'), 'alice')).toBe('The stock ran down: a draw.') + expect(headlineOf(ending('draw'), 'alice')).toBe('A draw') + expect(headlineOf(ending('gin', 'alice'), 'alice')).toBe('You won the hand!') }) }) diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css index 1a0c6ac3..4ef24440 100644 --- a/src/apps/rummy/components/RummyTable.module.css +++ b/src/apps/rummy/components/RummyTable.module.css @@ -217,3 +217,26 @@ button.meld { margin-left: -1.05rem; } } + +/* The dealer's pick: one list beside the Deal button, sized to sit with + the felt's buttons and big enough to tap. */ +.pick { + min-height: 2.5rem; + padding: 0.3rem 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 0.4rem; + background: rgba(0, 0, 0, 0.3); + color: white; + font: inherit; +} + +/* A gin hand at the end, meld by meld: each group kept whole on a line. */ +.ginMeld { + display: inline-block; + margin-right: 0.5rem; + white-space: nowrap; +} + +.ginHands td { + text-align: left; +} diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index 8d7f250a..d55f5db3 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { Fragment, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { CSSProperties, KeyboardEvent, ReactNode } from 'react' import type { RummyTableActions } from '@/hooks/useRummyTable' @@ -6,6 +6,7 @@ import type { HandOrder } from '../rules' import type { Card, RummyGameEnded, RummyLastMove, RummyPlayer, RummyView } from '../wire' import { arrangedMeld, + bestArrangement, canDeal, canDiscard, deadwood, @@ -15,6 +16,7 @@ import { enteredSince, face, headlineOf, + knockable, meldsFitting, seatOf, sortHand, @@ -38,7 +40,10 @@ import styles from './RummyTable.module.css' // // The table is dealer's choice (MoonBase#1609): between deals the last // deal's cards stay on the felt, face up, under a sheet with its result, -// and the dealer's pick deals the next. The table itself ends only when +// and the dealer's pick — one list, one Deal button — deals the next. +// Gin (#1610) plays on the same felt: an upcard to take or pass, no +// melding, and a knock where rummy would meld; the hub arranges both +// hands at the end and the sheet lays them out. The table itself ends only when // too few seats are left, with the hands each seat won. export interface RummyTableProps { @@ -62,6 +67,9 @@ const FAN_STEP = 4 // 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 ?? ''}` +// What the seat on turn is about to do. +const stageWords = (stage?: RummyView['stage']) => (stage === 'upcard' ? 'take or pass' : stage === 'draw' ? 'draw' : 'play') + const RummyTable = ({ playerId, connected, view, table, away = [], children }: RummyTableProps) => { const { ended, opening, dealing, selected, order } = table const headingRef = useRef(null) @@ -71,12 +79,16 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R const [endingRead, setEndingRead] = useState(null) const [dealRead, setDealRead] = useState(null) const [faded, setFaded] = useState(null) + // The game picked in the list, for the deal it was picked for. + const [picking, setPicking] = useState<{ deal: string; variant: string } | null>(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 gin = view.variant === 'gin' + const upcard = myTurn && view.stage === 'upcard' const drawing = myTurn && view.stage === 'draw' const laying = myTurn && view.stage === 'play' const between = view.phase === 'choosing' @@ -111,6 +123,10 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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 mayKnock = gin && discardable && single !== null && knockable(myHand, single) + // Gin: what the hand is reckoned by, and what a throw would leave. + const myDeadwood = gin ? bestArrangement(myHand).points : deadwood(myHand) + const leftAfter = gin && single !== null ? bestArrangement(myHand.filter(card => face(card) !== face(single))).points : null // 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, @@ -127,7 +143,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R const showDealEnd = between && lastDeal !== undefined && dealRead !== dealKey const keepFocusIn = (event: KeyboardEvent) => { if (event.key !== 'Tab') return - const focusable = endingRef.current?.querySelectorAll('button:not(:disabled)') + const focusable = endingRef.current?.querySelectorAll('button:not(:disabled), select:not(:disabled)') if (focusable === undefined || focusable.length === 0) return const first = focusable[0] const last = focusable[focusable.length - 1] @@ -170,6 +186,15 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R // An empty stock that can still be drawn is the discard pile, turned over // under its top card. const turning = view.stockCount === 0 && view.canDrawStock + // A hub older than gin does not say; there, the discard is drawable + // while it has a top. + const discardDrawable = view.canDrawDiscard ?? view.discardTop !== undefined + const takeable = (drawing || upcard) && discardDrawable + // Between deals a seat shows what the hub reckoned it held — gin's + // deadwood after its melds — or, for a seat not in the reckoning, its cards. + const leftIn = (seat: RummyPlayer) => lastDeal?.scores.find(score => score.playerId === seat.playerId)?.deadwood ?? deadwood(seat.hand) + // Gin is heads-up: the defender is the seat that did not knock. + const defenderOf = (knocker?: string) => view.players.find(seat => seat.playerId !== knocker)?.playerId const hint = (() => { if (view.phase === 'waiting') return view.players.length < 2 ? 'Waiting for a second seat.' : '' @@ -177,12 +202,19 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R if (!mayDeal) return `Waiting for ${dealer} to deal.` return dealer === playerId ? 'Your deal: pick the game.' : `${dealer} is away: you can deal.` } + if (upcard && view.discardTop !== undefined) return `Take the ${face(view.discardTop)}, or pass.` if (drawing) { + if (view.discardTop !== undefined && !discardDrawable) return 'The upcard was passed: draw from the stock.' if (view.discardTop === undefined) return view.canDrawStock ? 'Draw from the stock.' : 'Nothing left to draw.' if (!view.canDrawStock) return `The stock is out: take the ${face(view.discardTop)}.` if (turning) return `The stock is out: turn the discard pile over to draw, or take the ${face(view.discardTop)}.` return `Draw from the stock, or take the ${face(view.discardTop)}.` } + if (laying && gin) { + if (single !== null && leftAfter !== null) return `Throwing ${face(single)} leaves ${leftAfter} deadwood${mayKnock ? ': you can knock.' : '.'}` + if (picked.length > 1) return 'Pick one card to discard or knock with.' + return 'Discard, or knock with 10 or less deadwood left.' + } 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.' @@ -191,7 +223,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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 `Waiting for ${view.currentPlayerId} to ${stageWords(view.stage)}.` } return '' })() @@ -218,20 +250,33 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R ) - // One per variant on offer, in the hub's order. - const dealButtons = (focusFirst = false) => - (view.choosing?.options ?? []).map((variant, i) => ( - - )) + // The games on offer, in the hub's order, as one list and one button: + // the pick starts at the last deal's game, so dealing it again is a tap. + const options = view.choosing?.options ?? [] + const choice = [picking?.deal === dealKey ? picking.variant : undefined, view.variant].find((v): v is string => v !== null && v !== undefined && options.includes(v)) ?? options[0] + const dealButtons = (focusButton = false) => + choice !== undefined && ( + <> + {options.length > 1 && ( + + )} + + + ) const actions = (() => { if (view.phase === 'waiting') { @@ -242,18 +287,46 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R ) } if (between && mayDeal && !showDealEnd) return dealButtons() + if (upcard) { + return ( + <> + + + + ) + } if (drawing) { return ( <> - ) } + if (laying && gin) { + return ( + <> + {/* A knock the throw allows is the move to make: it leads. */} + + + {single !== null && !discardable && ( +

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

+ )} + + ) + } if (laying) { return ( <> @@ -299,16 +372,16 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R key={seat.playerId} className={`${felt.seat} ${mine ? felt.mine : ''} ${onTurn ? felt.onTurn : ''}`} data-clock={clock} - aria-label={`${label}${onTurn ? (view.stage === 'draw' ? ', to draw' : ', to play') : ''}`} + aria-label={`${label}${onTurn ? `, to ${stageWords(view.stage)}` : ''}`} >

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

@@ -356,11 +429,11 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
{mine && dealt && (
- - {deadwood(myHand)} pts in hand + + {gin ? `${myDeadwood} deadwood` : `${myDeadwood} pts in hand`} - {(['suit', 'rank'] as const).map(by => ( + {(['suit', 'rank', 'melds'] as const).map(by => ( @@ -424,7 +497,32 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R

{describeEnding(lastDeal, playerId)}

- {lastDeal.scores.length > 0 && ( + {lastDeal.gin !== undefined && ( + + + + {lastDeal.gin.hands.map(hand => ( + + + + + ))} + +
Each hand, melds then deadwood
{hand.playerId === playerId ? 'You' : hand.playerId} + {hand.melds.map(m => ( + + {m.map(face).join(' ')}{' '} + + ))} + {`${hand.deadwood.map(face).join(' ')} · ${deadwood(hand.deadwood)}`} +
+ )} + {lastDeal.gin !== undefined && lastDeal.gin.laidOff.length > 0 && ( +

+ {`${defenderOf(lastDeal.gin.knocker) === playerId ? 'You' : defenderOf(lastDeal.gin.knocker)} laid off ${lastDeal.gin.laidOff.map(face).join(' ')}.`} +

+ )} + {lastDeal.gin === undefined && lastDeal.scores.length > 0 && ( @@ -510,58 +608,60 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R ) : ( )} {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) => ( - - - - ))} - - {/* Who laid it: a lay-off can go on anyone's, but at three - or four seats it helps to know whose run is whose. */} -
+ )} )} diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 2b9f1c4c..5f561658 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -20,7 +20,7 @@ const myHand = ['K♦', '7♥', '7♣', '8♥', '9♥', '2♠'] const view = (over: Partial = {}): RummyView => ({ gameId: 'M1', phase: 'playing', - variant: 'basic', + variant: '7-card', dealNumber: 1, standings: [ { playerId: 'alice', handsWon: 0 }, @@ -31,6 +31,7 @@ const view = (over: Partial = {}): RummyView => ({ stage: 'play', stockCount: 30, canDrawStock: true, + canDrawDiscard: true, discardCount: 3, discardTop: c('Q♠'), melds: [ @@ -42,16 +43,30 @@ const view = (over: Partial = {}): RummyView => ({ // 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 => +const between = ({ + dealer, + dealNumber = 1, + lastDeal, + options = ['7-card'], + variant = '7-card' +}: { + dealer: string + dealNumber?: number + lastDeal?: RummyView['lastDeal'] + options?: string[] + variant?: string +}): RummyView => view({ phase: 'choosing', dealNumber, currentPlayerId: undefined, stage: undefined, canDrawStock: false, + canDrawDiscard: false, players: dealNumber === 0 ? [seat('alice', []), seat('bob', [])] : [seat('alice', myHand), seat('bob', ['A♠'])], melds: dealNumber === 0 ? [] : view().melds, - choosing: { dealer, options: ['basic'] }, + choosing: { dealer, options }, + variant, lastDeal }) @@ -67,10 +82,12 @@ const table = (over: Partial = {}): RummyTableProps['t playAgain: vi.fn(), drawStock: vi.fn(), drawDiscard: vi.fn(), + pass: vi.fn(), toggleCard: vi.fn(), meldSelected: vi.fn(), layOffSelected: vi.fn(), discardSelected: vi.fn(), + knockSelected: vi.fn(), setOrder: vi.fn(), ...over }) @@ -265,7 +282,7 @@ describe('RummyTable', () => { }) 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 lastDeal = { variant: '7-card', 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 the hand' })).toBeDefined() @@ -273,19 +290,19 @@ describe('RummyTable', () => { 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: 'Deal 7-card rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('7-card') 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('button', { name: 'Deal 7-card 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: [] } + const lastDeal = { variant: '7-card', 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() @@ -296,7 +313,7 @@ describe('RummyTable', () => { }) it('a deal broken up by a leave names nobody', () => { - mountWith(between({ lastDeal: { variant: 'basic', points: 0, scores: [] }, dealer: 'alice' })) + mountWith(between({ lastDeal: { variant: '7-card', points: 0, scores: [] }, dealer: 'alice' })) expect(within(screen.getByRole('dialog')).getByRole('heading', { name: 'The deal broke up' })).toBeDefined() }) @@ -305,8 +322,8 @@ describe('RummyTable', () => { 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') + fireEvent.click(screen.getByRole('button', { name: 'Deal 7-card rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('7-card') cleanup() mountWith(between({ dealer: 'bob', dealNumber: 0 })) expect(screen.getByText('Waiting for bob to deal.')).toBeDefined() @@ -326,7 +343,7 @@ describe('RummyTable', () => { }) 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: [] } })) + const { container } = mountWith(between({ dealer: 'bob', lastDeal: { variant: '7-card', winner: 'bob', points: 3, scores: [] } })) expect(container.querySelector('[data-showdown]')).not.toBeNull() cleanup() const live = mountWith(view()) @@ -334,7 +351,7 @@ describe('RummyTable', () => { }) it('the deal’s result is said once, by the sheet, and describes it', () => { - const lastDeal = { variant: 'basic', winner: 'bob', points: 3, scores: [] } + const lastDeal = { variant: '7-card', 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) @@ -343,7 +360,7 @@ describe('RummyTable', () => { }) 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 lastDeal = { variant: '7-card', winner: 'bob', points: 3, scores: [] } const { rerender, t } = mountWith(between({ dealer: 'bob', lastDeal })) expect(document.activeElement?.textContent).toBe('See the hands') rerender() @@ -351,26 +368,26 @@ describe('RummyTable', () => { }) 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 lastDeal = { variant: '7-card', 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') + expect(document.activeElement?.textContent).toBe('Deal 7-card 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: [] } })) + mountWith(between({ dealer: 'alice', lastDeal: { variant: '7-card', 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 lastDeal = { variant: '7-card', 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') + expect(document.activeElement?.textContent).toBe('Deal 7-card rummy') rerender() expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(true) }) @@ -378,8 +395,8 @@ describe('RummyTable', () => { 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') + fireEvent.click(screen.getByRole('button', { name: 'Deal 7-card rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('7-card') }) it('says which deal is on and what it plays, and the hands each seat has won', () => { @@ -392,11 +409,173 @@ describe('RummyTable', () => { ] }) ) - expect(screen.getByText('Deal 3 · Basic rummy')).toBeDefined() + expect(screen.getByText('Deal 3 · 7-card 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 dealer picks the game from a list, one Deal button, the last deal’s game first', () => { + const options = ['7-card', '10-card', 'gin'] + const { t } = mountWith(between({ dealer: 'alice', dealNumber: 0, options })) + const game = screen.getByRole('combobox', { name: 'Game' }) + expect( + within(game) + .getAllByRole('option') + .map(o => o.textContent) + ).toEqual(['7-card rummy', '10-card rummy', 'Gin rummy']) + expect(screen.getAllByRole('button', { name: /^Deal/ })).toHaveLength(1) + fireEvent.change(game, { target: { value: 'gin' } }) + fireEvent.click(screen.getByRole('button', { name: 'Deal Gin rummy' })) + expect(t.chooseVariant).toHaveBeenCalledWith('gin') + cleanup() + const lastDeal = { variant: '10-card', winner: 'bob', points: 3, scores: [] } + mountWith(between({ dealer: 'alice', options, variant: '10-card', lastDeal })) + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Deal 10-card rummy' })).toBeDefined() + }) + + it('with one game on offer there is nothing to pick', () => { + mountWith(between({ dealer: 'alice', dealNumber: 0 })) + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.getByRole('button', { name: 'Deal 7-card rummy' })).toBeDefined() + }) + + const gin = (over: Partial = {}) => view({ variant: 'gin', melds: [], ...over }) + + it('three seats offer two games, and the list is there to pick between them', () => { + mountWith(between({ dealer: 'alice', dealNumber: 0, options: ['7-card', '10-card'] })) + expect(within(screen.getByRole('combobox', { name: 'Game' })).getAllByRole('option')).toHaveLength(2) + }) + + it('the list is inside the sheet’s tab loop', () => { + const lastDeal = { variant: '7-card', winner: 'bob', points: 3, scores: [] } + mountWith(between({ dealer: 'alice', options: ['7-card', 'gin'], lastDeal })) + const game = screen.getByRole('combobox', { name: 'Game' }) + expect(document.activeElement?.textContent).toBe('Deal 7-card rummy') + screen.getByRole('button', { name: 'See the hands' }).focus() + fireEvent.keyDown(document.activeElement as Element, { key: 'Tab' }) + expect(document.activeElement).toBe(game) + fireEvent.keyDown(game, { key: 'Tab', shiftKey: true }) + expect(document.activeElement?.textContent).toBe('See the hands') + }) + + it('a game picked for one deal is not the pick for the next', () => { + const options = ['7-card', '10-card', 'gin'] + const { rerender, t } = mountWith(between({ dealer: 'alice', dealNumber: 0, options })) + fireEvent.change(screen.getByRole('combobox', { name: 'Game' }), { target: { value: 'gin' } }) + const later = between({ dealer: 'alice', dealNumber: 3, options, variant: '7-card', lastDeal: { variant: '7-card', winner: 'bob', points: 3, scores: [] } }) + rerender() + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'Deal 7-card rummy' })).toBeDefined() + }) + + it('against a hub that does not say whether the discard can be drawn, it can while there is one', () => { + mountWith(view({ stage: 'draw', canDrawDiscard: undefined })) + expect(screen.getByText('Draw from the stock, or take the Q♠.')).toBeDefined() + expect(screen.getByRole('button', { name: 'Take Q♠' })).toHaveProperty('disabled', false) + }) + + it('gin: the upcard is the opener’s to take; the other seat waits', () => { + mountWith(gin({ stage: 'upcard', currentPlayerId: 'bob', canDrawStock: false })) + expect(screen.getByText('Waiting for bob to take or pass.')).toBeDefined() + expect(screen.queryByRole('button', { name: 'Pass' })).toBeNull() + expect(screen.queryByRole('button', { name: /^Take/ })).toBeNull() + }) + + it('gin: a pass hands focus to the hand, since the button goes', () => { + mountWith(gin({ stage: 'upcard', canDrawStock: false })) + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + expect(document.activeElement).toBe(screen.getByRole('group', { name: 'Your hand' })) + }) + + it('gin: two cards picked says to pick one', () => { + mountWith(gin(), { selected: ['K♦', '2♠'] }) + expect(screen.getByText('Pick one card to discard or knock with.')).toBeDefined() + }) + + it('gin: the card just taken can no more be knocked on than thrown', () => { + mountWith(gin({ takenDiscard: c('K♦') }), { selected: ['K♦'] }) + expect(screen.getByRole('button', { name: 'Knock on K♦' })).toHaveProperty('disabled', true) + }) + + it('gin: between deals a seat shows the deadwood the hub reckoned, not its card total', () => { + const lastDeal = { + variant: 'gin', + winner: 'alice', + points: 9, + scores: [ + { playerId: 'alice', deadwood: 0 }, + { playerId: 'bob', deadwood: 9 } + ], + gin: { ending: 'knock' as const, knocker: 'alice', hands: [], laidOff: [] } + } + mountWith(between({ dealer: 'bob', variant: 'gin', lastDeal })) + expect(screen.getByRole('region', { name: /^bob/ }).textContent).toContain('9 pts left') + }) + + it('gin: the upcard is taken or passed, and the stock waits', () => { + const { t } = mountWith(gin({ stage: 'upcard', canDrawStock: false })) + expect(screen.getByText('Take the Q♠, or pass.')).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: 'Take Q♠' })) + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + expect(t.drawDiscard).toHaveBeenCalledTimes(1) + expect(t.pass).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: /draw from the stock,/ })).toBeNull() + expect(screen.getByRole('region', { name: /^alice \(you\), to take or pass/ })).toBeDefined() + }) + + it('gin: an upcard passed by both leaves only the stock', () => { + mountWith(gin({ stage: 'draw', canDrawDiscard: false })) + expect(screen.getByText('The upcard was passed: draw from the stock.')).toBeDefined() + expect(screen.getByRole('button', { name: 'Take Q♠' })).toHaveProperty('disabled', true) + expect(screen.queryByRole('button', { name: /take Q♠ from the discard pile/ })).toBeNull() + }) + + it('gin: no melding, and a knock is armed only by a throw that leaves ten or less', () => { + // 7-8-9♥ melds; K♦ 7♣ 2♠ are 19 over. + const knocking = mountWith(gin(), { selected: ['K♦'] }) + expect(screen.queryByRole('button', { name: /^Meld/ })).toBeNull() + expect(screen.queryByRole('group', { name: 'melds' })).toBeNull() + expect(screen.getByText('19 deadwood')).toBeDefined() + expect(screen.getByText('Throwing K♦ leaves 9 deadwood: you can knock.')).toBeDefined() + // An armed knock is the move to make: it leads, the discard follows. + expect(screen.getByRole('button', { name: 'Knock on K♦' }).className).toContain('primary') + expect(screen.getByRole('button', { name: 'Discard K♦' }).className).toContain('secondary') + fireEvent.click(screen.getByRole('button', { name: 'Knock on K♦' })) + expect(knocking.t.knockSelected).toHaveBeenCalledTimes(1) + cleanup() + mountWith(gin(), { selected: ['2♠'] }) + expect(screen.getByText('Throwing 2♠ leaves 17 deadwood.')).toBeDefined() + expect(screen.getByRole('button', { name: 'Knock on 2♠' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Discard 2♠' }).className).toContain('primary') + }) + + it('gin: the deal’s end lays out both hands as the hub arranged them', () => { + const lastDeal = { + variant: 'gin', + winner: 'bob', + points: 14, + scores: [ + { playerId: 'alice', deadwood: 17 }, + { playerId: 'bob', deadwood: 3 } + ], + gin: { + ending: 'knock' as const, + knocker: 'bob', + hands: [ + { playerId: 'alice', melds: [[c('7♥'), c('8♥'), c('9♥')]], deadwood: [c('7♣'), c('K♦')] }, + { playerId: 'bob', melds: [[c('A♠'), c('2♠'), c('3♠')], [c('Q♣'), c('Q♦'), c('Q♥')]], deadwood: [c('3♦')] } + ], + laidOff: [c('4♠')] + } + } + mountWith(between({ dealer: 'alice', variant: 'gin', options: ['7-card', 'gin'], lastDeal })) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.getByText('bob knocked and scores 14 points.')).toBeDefined() + expect(dialog.getByRole('row', { name: 'You 7♥ 8♥ 9♥ 7♣ K♦ · 17' })).toBeDefined() + expect(dialog.getByRole('row', { name: 'bob A♠ 2♠ 3♠ Q♣ Q♦ Q♥ 3♦ · 3' })).toBeDefined() + expect(dialog.getByText('You laid off 4♠.')).toBeDefined() + }) + + 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 }) diff --git a/src/apps/rummy/rules.ts b/src/apps/rummy/rules.ts index 1e6c6b5b..f2aa3529 100644 --- a/src/apps/rummy/rules.ts +++ b/src/apps/rummy/rules.ts @@ -49,15 +49,95 @@ 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 hand's best split into melds and deadwood: the least deadwood left, +// which is what gin reckons a hand by. +export interface Arrangement { + // Each as it would lie on the table. + melds: Card[][] + deadwood: Card[] + points: number +} + +// Every meld the hand could make: each three or four of a rank, and each +// stretch of three or more in a suit, the ace low or high. +function candidateMelds(hand: Card[]): Card[][] { + const found = new Map() + const add = (cards: Card[]) => { + const meld = arrangedMeld(cards) + if (meld !== null) found.set(meld.map(face).join(','), meld) + } + for (const rank of RANKS) { + const same = hand.filter(card => card.rank === rank) + if (same.length >= 3) add(same) + if (same.length === 4) same.forEach((_, skip) => add(same.filter((__, i) => i !== skip))) + } + for (const suit of HAND_SUITS) { + for (const aceHigh of [false, true]) { + const run = hand.filter(card => card.suit === suit).sort((a, b) => rankOf(a, aceHigh) - rankOf(b, aceHigh)) + for (let from = 0; from < run.length; from++) { + for (let to = from + 1; to < run.length && rankOf(run[to], aceHigh) === rankOf(run[to - 1], aceHigh) + 1; to++) { + if (to - from >= 2) add(run.slice(from, to + 1)) + } + } + } + } + return [...found.values()] +} + +export function bestArrangement(hand: Card[]): Arrangement { + const candidates = candidateMelds(hand) + const faces = hand.map(face) + const decided = new Set() + const chosen: Card[][] = [] + let best: Arrangement = { melds: [], deadwood: hand, points: deadwood(hand) } + // Card by card: each is deadwood or in a meld with cards still free. + // Deadwood only grows, so a branch already past the best is dropped. + const search = (at: number, points: number) => { + if (points >= best.points && at > 0) return + while (at < hand.length && decided.has(faces[at])) at++ + if (at === hand.length) { + best = { melds: [...chosen], deadwood: hand.filter(card => !chosen.some(meld => meld.includes(card))), points } + return + } + for (const meld of candidates) { + const cards = meld.map(face) + if (!cards.includes(faces[at]) || cards.some(f => decided.has(f))) continue + cards.forEach(f => decided.add(f)) + chosen.push(meld.map(card => hand[faces.indexOf(face(card))])) + search(at + 1, points) + chosen.pop() + cards.forEach(f => decided.delete(f)) + } + decided.add(faces[at]) + search(at + 1, points + cardPoints(hand[at])) + decided.delete(faces[at]) + } + search(0, 0) + return best +} + +// Gin: whether throwing this card leaves ten or less deadwood. +export function knockable(hand: Card[], card: Card): boolean { + if (!hand.some(held => face(held) === face(card))) return false + return bestArrangement(hand.filter(held => face(held) !== face(card))).points <= 10 +} + +export type HandOrder = 'suit' | 'rank' | 'melds' // 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 +// for sets, or each meld of its best arrangement together, then the +// deadwood. 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)) + const suitThenRank = (a: Card, b: Card) => bySuit(a, b) || byRank(a, b) + if (order === 'melds') { + const arranged = bestArrangement(hand) + const melds = [...arranged.melds].sort((a, b) => suitThenRank(a[0], b[0])) + return [...melds.flat(), ...[...arranged.deadwood].sort(suitThenRank)] + } + return [...hand].sort(order === 'suit' ? suitThenRank : (a, b) => byRank(a, b) || bySuit(a, b)) } // What a card left in hand costs at the end. @@ -93,6 +173,10 @@ export function describeLastMove(move: RummyLastMove, viewer: string): string { return `${who} laid off ${faces}` case 'discard': return `${who} discarded ${faces}` + case 'pass': + return `${who} passed on the upcard` + case 'knock': + return `${who} knocked on ${faces}` } } @@ -100,6 +184,14 @@ const points = (n: number) => `${n} point${n === 1 ? '' : 's'}` // How a deal's end reads from one chair. export function describeEnding(deal: RummyDealResult, viewer: string): string { + const gin = deal.gin + if (gin !== undefined) { + if (gin.ending === 'draw' || deal.winner === undefined) return 'The stock ran down: a draw.' + const scores = deal.winner === viewer ? `score ${points(deal.points)}` : `scores ${points(deal.points)}` + const who = you(deal.winner, viewer) + if (gin.ending === 'undercut') return `${who} undercut ${gin.knocker === viewer ? 'you' : gin.knocker} and ${scores}.` + return `${who} ${gin.ending === 'gin' ? 'went gin' : 'knocked'} and ${scores}.` + } if (deal.winner === undefined) return 'Nobody went out.' return deal.winner === viewer ? `You went out and score ${points(deal.points)}.` @@ -107,6 +199,7 @@ export function describeEnding(deal: RummyDealResult, viewer: string): string { } export function headlineOf(deal: RummyDealResult, viewer: string): string { + if (deal.gin?.ending === 'draw') return 'A draw' if (deal.winner === undefined) return 'The deal broke up' return deal.winner === viewer ? 'You won the hand!' : `${deal.winner} wins the hand` } @@ -128,7 +221,8 @@ export function canDeal(view: RummyView, viewer: string, away: string[]): boolea return view.choosing.dealer === viewer || away.includes(view.choosing.dealer) } -const VARIANT_LABELS: Record = { basic: 'Basic rummy' } +// `basic` is 7-card's name on a hub from before 7-card had its own. +const VARIANT_LABELS: Record = { '7-card': '7-card rummy', basic: '7-card rummy', '10-card': '10-card rummy', gin: 'Gin rummy' } // A variant as the table names it; one this build does not know yet reads // as the hub spelled it. diff --git a/src/apps/rummy/wire.ts b/src/apps/rummy/wire.ts index 1e11e2bc..637f1319 100644 --- a/src/apps/rummy/wire.ts +++ b/src/apps/rummy/wire.ts @@ -4,8 +4,9 @@ // 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. +// between deals on its dealer's pick — 7-card or 10-card rummy, or gin +// heads-up (#1610); 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' @@ -13,9 +14,10 @@ export type { Card } 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. -export type RummyStage = 'draw' | 'play' +// Where the seat on turn is: gin's upcard (take it or pass), about to +// draw, or holding the drawn card with melds, lay-offs and the discard to +// come — in gin, a discard or a knock. +export type RummyStage = 'upcard' | 'draw' | 'play' export interface RummyPlayer { playerId: string @@ -32,11 +34,12 @@ export interface RummyMeld { cards: Card[] } -export type RummyMoveKind = 'drawStock' | 'drawDiscard' | 'meld' | 'layOff' | 'discard' +export type RummyMoveKind = 'drawStock' | 'drawDiscard' | 'meld' | 'layOff' | 'discard' | 'pass' | 'knock' // 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. +// discarded or knocked on; nothing for a pass. The seat named may have +// left since. export interface RummyLastMove { playerId: string move: RummyMoveKind @@ -54,14 +57,35 @@ export interface RummyScore { 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. +// A gin hand as the hub arranged it at the end; the defender's without +// what it laid off. +export interface RummyArrangedHand { + playerId: string + melds: Card[][] + deadwood: Card[] +} + +export type RummyGinEnding = 'knock' | 'gin' | 'undercut' | 'draw' + +export interface RummyGinResult { + ending: RummyGinEnding + // Absent for a draw. + knocker?: string + // Seat order. + hands: RummyArrangedHand[] + // The defender's cards laid off onto the knocker's melds. + laidOff: Card[] +} + +// A deal's result: the seat that scored and what (everyone else's cards +// left in hand, or gin's reckoning), or no winner and no points for a +// deal broken up by a leave or drawn. export interface RummyDealResult { variant: string winner?: string points: number scores: RummyScore[] + gin?: RummyGinResult } // Between deals: who deals next and what they may deal. A dealer the @@ -90,6 +114,10 @@ export interface RummyView { // 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 + // Whether a discard draw would take the top: not once gin's upcard was + // passed by both. Absent from a hub older than gin, where it is whether + // there is a top. + canDrawDiscard?: boolean discardCount: number discardTop?: Card // Taken from the discard pile this turn: it may not go straight back @@ -125,11 +153,13 @@ export interface RummyMovePayloads { startGame: undefined leaveGame: undefined chooseVariant: { variant: string } + pass: undefined drawStock: undefined drawDiscard: undefined meld: { cards: Card[] } layOff: { card: Card; meldIndex: number } discard: { card: Card } + knock: { card: Card } } export type RummyMoveName = keyof RummyMovePayloads diff --git a/src/apps/stats/__tests__/rollup.test.ts b/src/apps/stats/__tests__/rollup.test.ts index aafdd312..4d22382c 100644 --- a/src/apps/stats/__tests__/rollup.test.ts +++ b/src/apps/stats/__tests__/rollup.test.ts @@ -219,6 +219,16 @@ describe('rollupHubEvents', () => { ]) }) + // A rummy table's deals are recorded by game: rummy at either hand size, + // gin as its own (MoonBase#1610). + it('names each rummy game the hub records', () => { + const labels = rollupHubEvents({ + days: 30, + rows: ['rummy', 'gin'].map(variant => ({ date: '2026-09-21', event: 'game_started', variant, surface: '', outcome: '', players: 2, events: 1 })), + }).variants.map(v => v.label) + expect(labels.sort()).toEqual(['Gin', 'Rummy']) + }) + // game_finished's players is the seats still held — 1 for nearly every // abandonment — so only game_started may reach this table. A finish // folded in here would report a flood of one-player tables that were diff --git a/src/apps/stats/rollup.ts b/src/apps/stats/rollup.ts index b21a8744..1e73c5f8 100644 --- a/src/apps/stats/rollup.ts +++ b/src/apps/stats/rollup.ts @@ -301,6 +301,7 @@ export const VARIANT_LABELS: Record = { golf: 'Golf', castle: 'Castle', rummy: 'Rummy', + gin: 'Gin', other: 'Other', } diff --git a/src/hooks/__tests__/useRummyTable.test.tsx b/src/hooks/__tests__/useRummyTable.test.tsx index 2816aa0d..c8e51bc6 100644 --- a/src/hooks/__tests__/useRummyTable.test.tsx +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -20,7 +20,7 @@ const seat = (playerId: string, hand: string[] = []): RummyPlayer => ({ const view = (over: Partial = {}): RummyView => ({ gameId: 'GAME01', phase: 'playing', - variant: 'basic', + variant: '7-card', dealNumber: 1, standings: [ { playerId: 'bob', handsWon: 0 }, @@ -31,6 +31,7 @@ const view = (over: Partial = {}): RummyView => ({ stage: 'play', stockCount: 30, canDrawStock: true, + canDrawDiscard: true, discardCount: 1, discardTop: c('2♠'), melds: [{ owner: 'bob', cards: [c('4♣'), c('5♣'), c('6♣')] }], @@ -124,12 +125,23 @@ describe('useRummyTable', () => { expect(result.current.selected).toEqual([]) }) - it('the draws are bare moves', () => { + it('the draws and gin’s pass 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']]) + act(() => result.current.pass()) + expect(move.mock.calls).toEqual([['drawStock'], ['drawDiscard'], ['pass']]) + }) + + it('a knock names the one card thrown', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view() } }) + act(() => result.current.toggleCard(c('K♣'))) + act(() => result.current.knockSelected()) + act(() => result.current.toggleCard(c('7♥'))) + act(() => result.current.knockSelected()) + expect(move.mock.calls).toEqual([['knock', { card: c('K♣') }]]) }) it('the hand order is the viewer’s, and outlives the table', () => { @@ -138,7 +150,11 @@ describe('useRummyTable', () => { act(() => first.result.current.setOrder('rank')) expect(first.result.current.order).toBe('rank') first.unmount() - expect(mount().result.current.order).toBe('rank') + const second = mount() + expect(second.result.current.order).toBe('rank') + act(() => second.result.current.setOrder('melds')) + second.unmount() + expect(mount().result.current.order).toBe('melds') }) it('a turn and the table starting are the felt’s to show; another table opening is a toast', () => { @@ -153,16 +169,16 @@ describe('useRummyTable', () => { 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' }]]) + act(() => result.current.chooseVariant('7-card')) + expect(move.mock.calls).toEqual([['chooseVariant', { variant: '7-card' }]]) expect(result.current.dealing).toBe(true) receive({ gameState: { view: view() } }) expect(result.current.dealing).toBe(false) - act(() => result.current.chooseVariant('basic')) + act(() => result.current.chooseVariant('7-card')) act(() => result.current.handleRejected()) expect(result.current.dealing).toBe(false) // A table joined meanwhile answers it too. - act(() => result.current.chooseVariant('basic')) + act(() => result.current.chooseVariant('7-card')) receive({ gameJoined: { view: view() } }) expect(result.current.dealing).toBe(false) }) diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts index e8f26c7b..62eb2e00 100644 --- a/src/hooks/useRummyTable.ts +++ b/src/hooks/useRummyTable.ts @@ -11,7 +11,9 @@ import { safeLocalStorage } from '@/utils/safeLocalStorage' // 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') +const ORDERS: HandOrder[] = ['suit', 'rank', 'melds'] + +const storedOrder = (): HandOrder => ORDERS.find(order => order === safeLocalStorage.get(ORDER_KEY)) ?? 'suit' // What the table's chrome calls; the lobby panel adds create and join. export interface RummyTableActions { @@ -24,6 +26,8 @@ export interface RummyTableActions { playAgain: () => void drawStock: () => void drawDiscard: () => void + // Gin: turn the upcard down. + pass: () => 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 @@ -31,6 +35,8 @@ export interface RummyTableActions { // The one selected card, onto the meld at this place on the table. layOffSelected: (meldIndex: number) => void discardSelected: () => void + // Gin: end the deal throwing the one selected card. + knockSelected: () => void setOrder: (order: HandOrder) => void } @@ -153,6 +159,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa const drawStock = useCallback(() => move('drawStock'), [move]) const drawDiscard = useCallback(() => move('drawDiscard'), [move]) + const pass = useCallback(() => move('pass'), [move]) const toggleCard = useCallback((card: Card) => { const picked = face(card) @@ -193,6 +200,12 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa move('discard', { card: cards[0] }) }, [move, selectedCards]) + const knockSelected = useCallback(() => { + const cards = selectedCards() + if (cards === null || cards.length !== 1) return + move('knock', { card: cards[0] }) + }, [move, selectedCards]) + const setOrder = useCallback((next: HandOrder) => { setOrderState(next) safeLocalStorage.set(ORDER_KEY, next) @@ -216,10 +229,12 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa playAgain, drawStock, drawDiscard, + pass, toggleCard, meldSelected, layOffSelected, discardSelected, + knockSelected, setOrder } }
Points left in hand