diff --git a/src/apps/rummy/__tests__/arrange_corpus.json b/src/apps/rummy/__tests__/arrange_corpus.json deleted file mode 100644 index 88699e4..0000000 --- a/src/apps/rummy/__tests__/arrange_corpus.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "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 c5f9922..2db3466 100644 --- a/src/apps/rummy/__tests__/rules.test.ts +++ b/src/apps/rummy/__tests__/rules.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { arrangedMeld, - bestArrangement, canDeal, canDiscard, deadwood, @@ -9,13 +8,11 @@ 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 @@ -113,17 +110,17 @@ describe('canDiscard', () => { expect(canDiscard(view(taken), cards('9♠'), c('9♠'))).toBe(true) expect(canDiscard(view(), cards('9♠', '2♣'), c('9♠'))).toBe(true) }) - it('refuses every discard while a card taken down to is still owed', () => { - const owing = view({ mustPlay: c('5♥') }) - expect(canDiscard(owing, cards('5♥', '2♣'), c('2♣'))).toBe(false) - expect(canDiscard(owing, cards('5♥', '2♣'), c('5♥'))).toBe(false) - }) }) 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: 'takeDown', cards: cards('5♥', '6♥', 'K♠'), meldIndex: 2 }, 'alice')).toBe( + 'bob took the pile down to 5♥ and played it' + ) + // A frame naming no card still reads. + expect(describeLastMove({ playerId: 'bob', move: 'takeDown', cards: [], meldIndex: 2 }, 'alice')).toBe('bob took the pile down') expect(describeLastMove({ playerId: 'bob', move: 'meld', cards: cards('7♥', '8♥', '9♥'), meldIndex: 0 }, 'alice')).toBe( 'bob melded 7♥ 8♥ 9♥' ) @@ -200,31 +197,6 @@ describe('the dealer’s choice', () => { }) }) -// 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♣') }, @@ -238,19 +210,6 @@ describe('the ace, in every variant', () => { 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', () => { diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css index 04b5537..ae53493 100644 --- a/src/apps/rummy/components/RummyTable.module.css +++ b/src/apps/rummy/components/RummyTable.module.css @@ -103,21 +103,6 @@ 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; @@ -328,7 +313,7 @@ button.meld { max-width: var(--spread-max); overflow: auto hidden; /* Room inside the scroll box for a raised card and its outline. */ - padding: 0.8rem 0.3rem 0.3rem; + padding: 0.9rem 0.3rem 0.3rem; } .discardSpread > * { @@ -358,17 +343,33 @@ button.meld { } } -/* The card the pile was taken down to, still owed to the table. */ -.owed { - outline: 2px solid #e8674a; - outline-offset: -4px; -} - -/* A card the pile may be taken down to rises out of the spread, over its - neighbours, so all of it is there to tap. */ +/* The top card, to take, rises out of the spread over its neighbours, so + all of it is there to tap. */ .discardSpread > .drawable, .discardSpread > .drawable:enabled:hover { position: relative; z-index: 1; transform: translateY(-0.6rem); } + +/* On the draw any card under the top can be picked to take the pile down + to: all alike, none lit. The one picked rises and is outlined. */ +.pickable { + cursor: pointer; +} + +.discardSpread > .pickable:enabled:hover, +.discardSpread > .pickable:focus-visible { + position: relative; + z-index: 2; + transform: translateY(-0.3rem); +} + +.discardSpread > .pickable[aria-pressed='true'], +.discardSpread > .pickable[aria-pressed='true']:enabled:hover { + position: relative; + z-index: 1; + transform: translateY(-0.6rem); + outline: 2px solid #f0c96a; + outline-offset: 2px; +} diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index f8b50fa..80a3c01 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -6,7 +6,6 @@ import type { HandOrder } from '../rules' import type { Card, RummyGameEnded, RummyLastMove, RummyPlayer, RummyView } from '../wire' import { arrangedMeld, - bestArrangement, canDeal, canDiscard, deadwood, @@ -16,7 +15,6 @@ import { enteredSince, face, headlineOf, - knockable, meldsFitting, seatOf, sortHand, @@ -51,7 +49,7 @@ export interface RummyTableProps { playerId: string connected: boolean view: RummyView - table: RummyTableActions & { ended: RummyGameEnded | null; selected: string[]; order: HandOrder; opening: boolean; dealing: boolean } + table: RummyTableActions & { ended: RummyGameEnded | null; selected: string[]; downTo: string | null; order: HandOrder; opening: boolean; dealing: boolean } // Seats the room shows as not connected: a dealer among them lets anyone // deal. away?: string[] @@ -72,7 +70,7 @@ const moveSignature = (move: RummyLastMove): string => `${move.playerId}:${move. 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 { ended, opening, dealing, selected, downTo, order } = table const headingRef = useRef(null) useEffect(() => { headingRef.current?.focus() @@ -119,15 +117,19 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R }) } + // A take-down in the making: on the draw, a card deeper in the pile + // picked to take it down to, played at once from the hand's cards. + const downToCard = drawing && !gin && downTo !== null ? ((view.discardPile ?? []).slice(0, -1).find(card => face(card) === downTo) ?? null) : null + const handPickable = laying || downToCard !== null 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 downMeld = downToCard !== null && picked.length >= 2 ? arrangedMeld([...picked, downToCard]) : null const single = laying && picked.length === 1 ? picked[0] : null const fitting = single === null ? [] : meldsFitting(view.melds, single) + // The pile card being taken down to may be laid off on any meld, none + // lit: whether it fits is the player's to see and the hub's to judge. + const downLaysOff = downToCard !== null && picked.length === 0 ? downToCard : null 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, @@ -190,11 +192,13 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R // 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 + // A take-down in the making is put back before any other draw. + const takeable = (drawing || upcard) && discardDrawable && downToCard === null // Rummy lays its whole discard pile out; gin keeps it squared, top only. const pile = gin ? [] : (view.discardPile ?? []) - // The deeper cards the viewer may take the pile down to, on its draw. - const deeper = drawing && !gin ? (view.discardTakeable ?? []).map(face).filter(f => view.discardTop === undefined || f !== face(view.discardTop)) : [] + // On the draw, any card under the top may be taken down to; which of + // them the hand could play is the player's to see. + const deeperPickable = drawing && pile.length > 1 // 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) @@ -219,23 +223,25 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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.' - const down = deeper.length > 0 ? ', or take the pile down to a lit card' : '' + if (downToCard !== null) { + const bottom = face(downToCard) + if (picked.length >= 2 && downMeld === null) return `Those cards and the ${bottom} are not a set or a run.` + if (picked.length > 0) return `Pick cards to meld with the ${bottom}, or clear them to lay it off.` + return `Pick cards from your hand to meld with the ${bottom}, or tap a meld to lay it off.` + } + const down = deeperPickable ? ', or pick a card deeper in the pile to take it down to' : '' if (!view.canDrawStock) return `The stock is out: take the ${face(view.discardTop)}${down}.` if (turning) return `The stock is out: turn the discard pile over to draw, or take the ${face(view.discardTop)}${down}.` - if (deeper.length > 0) return `Draw from the stock, take the ${face(view.discardTop)}${down}.` + if (deeperPickable) return `Draw from the stock, take the ${face(view.discardTop)}${down}.` 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)}${view.mustPlay === undefined ? ', or discard it' : ''}.` + 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.' - if (view.mustPlay !== undefined) { - return `Play the ${face(view.mustPlay)} you took the pile down to — meld it or lay it off — before you discard.` - } if (picked.length === 2) return 'Pick three or more to meld, or one to lay off or discard.' 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.' @@ -317,6 +323,18 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R ) } + if (drawing && downToCard !== null) { + return ( + <> + + + + ) + } if (drawing) { return ( <> @@ -332,11 +350,10 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R if (laying && gin) { return ( <> - {/* A knock the throw allows is the move to make: it leads. */} - - {single !== null && view.takenDiscard !== undefined && face(single) === face(view.takenDiscard) && !discardable && ( @@ -416,11 +433,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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) - // A card that would grow a meld on the table is marked - // before it is picked, so a lay-off is seen, not guessed. - const laysOff = card !== null && laying && mine && meldsFitting(view.melds, card).length > 0 - const owed = card !== null && mine && view.mustPlay !== undefined && face(view.mustPlay) === face(card) - const notes = [owed ? 'must be played' : '', taken ? 'just taken' : '', laysOff ? 'fits a meld' : ''].filter(Boolean) + const notes = taken ? ['just taken'] : [] return ( 0 ? [face(card), ...notes].join(', ') : undefined} - toggle={mine && laying ? selected.includes(face(card)) : undefined} - onClick={mine && laying && connected ? () => table.toggleCard(card) : undefined} + toggle={mine && handPickable ? selected.includes(face(card)) : undefined} + onClick={mine && handPickable && connected ? () => table.toggleCard(card) : undefined} /> )} @@ -448,11 +461,14 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R {mine && dealt && (
- - {gin ? `${myDeadwood} deadwood` : `${myDeadwood} pts in hand`} - + {/* Gin's reckoning is deadwood after melds: the player's to count. */} + {!gin && ( + + {`${deadwood(myHand)} pts in hand`} + + )} - {(['suit', 'rank', 'melds'] as const).map(by => ( + {(['suit', 'rank'] as const).map(by => ( @@ -609,7 +625,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
- {drawing && view.canDrawStock ? ( + {drawing && view.canDrawStock && downToCard === null ? ( diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 5ea98cb..90c302d 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -73,6 +73,7 @@ const between = ({ const table = (over: Partial = {}): RummyTableProps['table'] => ({ ended: null, selected: [], + downTo: null, order: 'suit', opening: false, dealing: false, @@ -82,6 +83,9 @@ const table = (over: Partial = {}): RummyTableProps['t playAgain: vi.fn(), drawStock: vi.fn(), drawDiscard: vi.fn(), + pickDownTo: vi.fn(), + takeDownMeld: vi.fn(), + takeDownLayOff: vi.fn(), pass: vi.fn(), toggleCard: vi.fn(), meldSelected: vi.fn(), @@ -180,6 +184,8 @@ describe('RummyTable', () => { rerender() expect(faces()).toEqual(['2♠', '7♥', '7♣', '8♥', '9♥', 'K♦']) expect(screen.getByRole('button', { name: 'by rank' }).getAttribute('aria-pressed')).toBe('true') + // Seeing the melds in a hand is the player's game: no order finds them. + expect(screen.queryByRole('button', { name: 'by melds' })).toBeNull() // The hand's cost, for deciding what to throw. expect(screen.getByText('43 pts in hand')).toBeDefined() }) @@ -210,16 +216,11 @@ describe('RummyTable', () => { expect(t.discardSelected).toHaveBeenCalledTimes(1) }) - it('marks the hand cards that would grow a meld before any is picked', () => { + it('marks nothing in hand as fitting a meld: that is the player’s to see', () => { mountWith(view()) - // 7♣ runs on from 4♣ 5♣ 6♣; nothing else fits either meld. - expect(myHandGroup().getByRole('button', { name: '7♣, fits a meld' })).toBeDefined() - expect(myHandGroup().getAllByRole('button', { name: /fits a meld/ })).toHaveLength(1) - // Its twin: off the play stage nothing is marked. - cleanup() - mountWith(view({ stage: 'draw' })) - expect(screen.queryByRole('img', { name: /fits a meld/ })).toBeNull() - expect(screen.getByRole('img', { name: '7♣' })).toBeDefined() + // 7♣ runs on from 4♣ 5♣ 6♣, and nothing says so. + expect(myHandGroup().getByRole('button', { name: '7♣' })).toBeDefined() + expect(myHandGroup().queryAllByRole('button', { name: /fits a meld/ })).toHaveLength(0) }) it('says who laid each meld', () => { @@ -494,6 +495,7 @@ describe('RummyTable', () => { 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) + expect(screen.getByRole('button', { name: 'Discard K♦' })).toHaveProperty('disabled', true) }) it('gin: between deals a seat shows the deadwood the hub reckoned, not its card total', () => { @@ -529,23 +531,16 @@ describe('RummyTable', () => { 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. + it('gin: no melding, and no reckoning: any throw may be knocked on, and the hub judges it', () => { 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') + // No count of the hand at all: its deadwood is the player's to reckon. + expect(screen.queryByText(/pts in hand|\d+ deadwood|leaves/)).toBeNull() + expect(screen.getByText('Discard, or knock with 10 or less deadwood left.')).toBeDefined() 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') + expect(screen.getByRole('button', { name: 'Discard K♦' })).toHaveProperty('disabled', false) }) it('gin: the deal’s end lays out both hands as the hub arranged them', () => { @@ -586,48 +581,79 @@ describe('RummyTable', () => { ]) }) - it('on the draw, the pile can be taken down to any card the seat could then play', () => { - const { t } = mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('9♣'), c('Q♠')], discardTakeable: [c('9♣'), c('Q♠')] })) - expect(screen.getByText('Draw from the stock, take the Q♠, or take the pile down to a lit card.')).toBeDefined() - fireEvent.click(screen.getByRole('button', { name: 'take the pile down to 9♣' })) - expect(t.drawDiscard).toHaveBeenCalledWith(c('9♣')) - // A card it could not play is no offer. - expect(screen.queryByRole('button', { name: /down to 4♦/ })).toBeNull() + it('on the draw every deeper card of the pile can be picked, and none is singled out', () => { + const { t } = mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('9♣'), c('Q♠')] })) + expect(screen.getByText('Draw from the stock, take the Q♠, or pick a card deeper in the pile to take it down to.')).toBeDefined() + const deeper = [screen.getByRole('button', { name: 'take the pile down to 4♦' }), screen.getByRole('button', { name: 'take the pile down to 9♣' })] + // Alike but for the suit's colour: nothing says which the hand could play. + const marks = (el: HTMLElement) => el.className.split(/\s+/).filter(name => !/red/.test(name) && name !== '') + expect(marks(deeper[0])).toEqual(marks(deeper[1])) + fireEvent.click(deeper[1]) + expect(t.pickDownTo).toHaveBeenCalledWith(c('9♣')) + // The top is a plain draw. fireEvent.click(screen.getByRole('button', { name: 'take Q♠ from the discard pile' })) - expect(t.drawDiscard).toHaveBeenLastCalledWith() - }) - - it('the card taken down to is owed: marked in hand, and no discard until it is played', () => { - mountWith(view({ mustPlay: c('9♥') }), { selected: ['K♦'] }) - expect(screen.getByText('Play the 9♥ you took the pile down to — meld it or lay it off — before you discard.')).toBeDefined() - expect(screen.getByRole('button', { name: 'Discard K♦' })).toHaveProperty('disabled', true) - expect(myHandGroup().getByRole('button', { name: /^9♥, must be played/ })).toBeDefined() - expect(myHandGroup().getAllByRole('button', { name: /must be played/ })).toHaveLength(1) + expect(t.drawDiscard).toHaveBeenCalledWith() }) - it('with only the top to take, the hint offers no take-down', () => { - mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('9♣'), c('Q♠')], discardTakeable: [c('Q♠')] })) + it('a pile of one has nothing deeper to take down to', () => { + mountWith(view({ stage: 'draw', discardPile: [c('Q♠')] })) expect(screen.getByText('Draw from the stock, or take the Q♠.')).toBeDefined() }) - it('owing a card, a barred discard is blamed on the debt, not on a take', () => { - mountWith(view({ mustPlay: c('9♥') }), { selected: ['K♦'] }) - expect(screen.queryByText(/can’t go straight back/)).toBeNull() + it('a card picked to take down to: the hand picks its meld, and Meld takes the pile down', () => { + const pile = [c('4♦'), c('7♦'), c('Q♠')] + const { t } = mountWith(view({ stage: 'draw', discardPile: pile }), { downTo: '7♦' }) + expect(screen.getByRole('button', { name: 'take the pile down to 7♦' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByText('Pick cards from your hand to meld with the 7♦, or tap a meld to lay it off.')).toBeDefined() + // The hand is picked from on the draw now. + fireEvent.click(myHandGroup().getByRole('button', { name: '7♣' })) + expect(t.toggleCard).toHaveBeenCalledWith(c('7♣')) + expect(screen.getByRole('button', { name: 'Take down and meld' })).toHaveProperty('disabled', true) + cleanup() + const armed = mountWith(view({ stage: 'draw', discardPile: pile }), { downTo: '7♦', selected: ['7♥', '7♣'] }) + fireEvent.click(screen.getByRole('button', { name: 'Take down and meld 7♣ 7♦ 7♥' })) + expect(armed.t.takeDownMeld).toHaveBeenCalledTimes(1) }) - it('owing a card, laying off and melding still say what they would do', () => { - mountWith(view({ mustPlay: c('9♥') }), { selected: ['7♣'] }) - expect(screen.getByText('Tap a lit meld to lay off 7♣.')).toBeDefined() + it('taking down, the hint follows the hand cards picked', () => { + const pile = [c('4♦'), c('7♦'), c('Q♠')] + mountWith(view({ stage: 'draw', discardPile: pile }), { downTo: '7♦', selected: ['7♥'] }) + expect(screen.getByText('Pick cards to meld with the 7♦, or clear them to lay it off.')).toBeDefined() cleanup() - mountWith(view({ mustPlay: c('9♥') }), { selected: ['K♦', '7♣', '2♠'] }) - expect(screen.getByText('Those cards are not a set or a run.')).toBeDefined() + mountWith(view({ stage: 'draw', discardPile: pile }), { downTo: '7♦', selected: ['K♦', '2♠'] }) + expect(screen.getByText('Those cards and the 7♦ are not a set or a run.')).toBeDefined() }) - it('with the stock out, the lit cards are still offered', () => { - mountWith(view({ stage: 'draw', stockCount: 0, canDrawStock: false, discardPile: [c('4♦'), c('9♣'), c('Q♠')], discardTakeable: [c('9♣'), c('Q♠')] })) - expect(screen.getByText('The stock is out: take the Q♠, or take the pile down to a lit card.')).toBeDefined() + it('taking down, a hand card picked is pressed, and the melds are no lay-off for the pile card', () => { + mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('3♣'), c('Q♠')] }), { downTo: '3♣', selected: ['7♣'] }) + expect(myHandGroup().getByRole('button', { name: '7♣' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.queryByRole('button', { name: /^lay off/ })).toBeNull() }) + it('taking down, the stock and the top are not draws: put the card back first', () => { + mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('7♦'), c('Q♠')] }), { downTo: '7♦' }) + expect(screen.queryByRole('button', { name: /draw from the stock/ })).toBeNull() + expect(screen.queryByRole('button', { name: /take Q♠/ })).toBeNull() + expect(screen.getByRole('img', { name: 'Q♠ on the discard pile' })).toBeDefined() + }) + + it('putting the pile card back hands focus to the hand, since the button goes', () => { + const { t } = mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('7♦'), c('Q♠')] }), { downTo: '7♦' }) + fireEvent.click(screen.getByRole('button', { name: 'Put the 7♦ back' })) + expect(t.pickDownTo).toHaveBeenCalledWith(c('7♦')) + expect(document.activeElement).toBe(screen.getByRole('group', { name: 'Your hand' })) + }) + + it('a card picked to take down to may be laid off on any meld, none lit: the hub judges the fit', () => { + const { t } = mountWith(view({ stage: 'draw', discardPile: [c('4♦'), c('3♣'), c('Q♠')] }), { downTo: '3♣' }) + const runs = screen.getByRole('button', { name: "lay off 3♣ on 4♣ 5♣ 6♣, bob's" }) + const jacks = screen.getByRole('button', { name: "lay off 3♣ on J♣ J♦ J♥, bob's" }) + expect(runs.className).toBe(jacks.className) + fireEvent.click(runs) + expect(t.takeDownLayOff).toHaveBeenCalledWith(0) + }) + + it('a long pile opens on its top card, scrolled to the end', () => { const scrolled: number[] = [] const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(900) @@ -645,8 +671,9 @@ describe('RummyTable', () => { }) it('gin keeps the pile squared: only its top shows, and only it is taken', () => { - mountWith(view({ variant: 'gin', melds: [], stage: 'draw', discardPile: [c('4♦'), c('Q♠')], discardTakeable: [] })) + mountWith(view({ variant: 'gin', melds: [], stage: 'draw', discardPile: [c('4♦'), c('Q♠')] })) expect(screen.queryByRole('img', { name: '4♦ in the discard pile' })).toBeNull() + expect(screen.queryByRole('button', { name: /take the pile down/ })).toBeNull() expect(screen.getByRole('button', { name: 'take Q♠ from the discard pile' })).toBeDefined() }) diff --git a/src/apps/rummy/rules.ts b/src/apps/rummy/rules.ts index cdc34da..ad91b92 100644 --- a/src/apps/rummy/rules.ts +++ b/src/apps/rummy/rules.ts @@ -49,94 +49,15 @@ export function meldsFitting(melds: RummyMeld[], card: Card): number[] { return melds.flatMap((meld, i) => (arrangedMeld([...meld.cards, card]) === null ? [] : [i])) } -// 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' +export type HandOrder = 'suit' | 'rank' // A copy of the hand in the order asked for: by suit for runs, by rank -// 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. +// for sets. The hub keeps the hand in the order it arrived; this is only +// how it is laid out. Which cards meld is the player's to see. 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) 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)) } @@ -150,12 +71,9 @@ export function deadwood(hand: Card[]): number { return hand.reduce((sum, card) => sum + cardPoints(card), 0) } -// The card just taken alone from the discard pile may not go straight -// back, unless it is all the hand has left; and nothing goes down while a -// card the pile was taken down to is still owed. +// 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 { - // A card the pile was taken down to is owed to the table first. - if (view.mustPlay !== undefined) return false return view.takenDiscard === undefined || face(view.takenDiscard) !== face(card) || hand.length === 1 } @@ -170,6 +88,8 @@ export function describeLastMove(move: RummyLastMove, viewer: string): string { return `${who} drew from the stock` case 'drawDiscard': return `${who} took ${faces}` + case 'takeDown': + return move.cards.length === 0 ? `${who} took the pile down` : `${who} took the pile down to ${face(move.cards[0])} and played it` case 'meld': return `${who} melded ${faces}` case 'layOff': diff --git a/src/apps/rummy/wire.ts b/src/apps/rummy/wire.ts index 3371914..1655457 100644 --- a/src/apps/rummy/wire.ts +++ b/src/apps/rummy/wire.ts @@ -34,12 +34,12 @@ export interface RummyMeld { cards: Card[] } -export type RummyMoveKind = 'drawStock' | 'drawDiscard' | 'meld' | 'layOff' | 'discard' | 'pass' | 'knock' +export type RummyMoveKind = 'drawStock' | 'drawDiscard' | 'takeDown' | '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 or knocked on; nothing for a pass. The seat named may have -// left since. +// sees it), the card taken, the cards a take-down took (the one it played +// first), the meld as laid, the card laid off, the card discarded or +// knocked on; nothing for a pass. The seat named may have left since. export interface RummyLastMove { playerId: string move: RummyMoveKind @@ -133,13 +133,6 @@ export interface RummyView { discardTop?: Card // The whole pile, bottom to top, face up for everyone. discardPile?: Card[] - // The cards the viewer may take the pile down to now, bottom to top: - // the top, and each deeper card it could then play. Empty but on the - // viewer's own draw, and in gin. - discardTakeable?: Card[] - // The deepest card the seat on turn took the pile down to, while it is - // still in hand: the turn cannot end until it is played. - mustPlay?: Card // Taken from the discard pile this turn: it may not go straight back // unless it is the last card in hand. takenDiscard?: Card @@ -177,8 +170,11 @@ export interface RummyMovePayloads { chooseVariant: { variant: string } pass: undefined drawStock: undefined - // The deepest card to take; none, the top. - drawDiscard: { card: Card } | undefined + drawDiscard: undefined + // Every card of the discard pile from the top down to `card`, which is + // played at once: melded with `cards` from the hand, or laid off onto + // the table meld `meldIndex`. + takeDown: { card: Card; cards: Card[] } | { card: Card; meldIndex: number } meld: { cards: Card[] } layOff: { card: Card; meldIndex: number } discard: { card: Card } diff --git a/src/hooks/__tests__/useRummyTable.test.tsx b/src/hooks/__tests__/useRummyTable.test.tsx index 87dee81..2107e34 100644 --- a/src/hooks/__tests__/useRummyTable.test.tsx +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -134,11 +134,59 @@ describe('useRummyTable', () => { expect(move.mock.calls).toEqual([['drawStock'], ['drawDiscard'], ['pass']]) }) - it('taking the pile down names the deepest card', () => { + it('taking the pile down: a pile card picked, then the hand’s meld or a lay-off', () => { const { result, receive, move } = mount() - receive({ gameJoined: { view: view({ stage: 'draw' }) } }) - act(() => result.current.drawDiscard(c('7♥'))) - expect(move.mock.calls).toEqual([['drawDiscard', { card: c('7♥') }]]) + const pile = { discardPile: [c('5♥'), c('6♥'), c('2♠')], discardCount: 3 } + receive({ gameJoined: { view: view({ stage: 'draw', ...pile }) } }) + act(() => result.current.pickDownTo(c('6♥'))) + expect(result.current.downTo).toBe('6♥') + act(() => result.current.toggleCard(c('7♥'))) + act(() => result.current.toggleCard(c('8♥'))) + act(() => result.current.takeDownMeld()) + act(() => result.current.takeDownLayOff(0)) + expect(move.mock.calls).toEqual([ + ['takeDown', { card: c('6♥'), cards: [c('7♥'), c('8♥')] }], + ['takeDown', { card: c('6♥'), meldIndex: 0 }] + ]) + // A second tap puts the pile card back, and the hand cards picked for + // it with it; the next view clears a pick too. + act(() => result.current.pickDownTo(c('6♥'))) + expect(result.current.downTo).toBeNull() + expect(result.current.selected).toEqual([]) + act(() => result.current.pickDownTo(c('5♥'))) + receive({ gameState: { view: view({ stage: 'draw', ...pile }) } }) + expect(result.current.downTo).toBeNull() + }) + + it('picking another pile card drops the hand cards picked for the first', () => { + const { result, receive } = mount() + receive({ gameJoined: { view: view({ stage: 'draw', discardPile: [c('5♥'), c('6♥'), c('2♠')] }) } }) + act(() => result.current.pickDownTo(c('6♥'))) + act(() => result.current.toggleCard(c('7♥'))) + act(() => result.current.pickDownTo(c('5♥'))) + expect(result.current.downTo).toBe('5♥') + expect(result.current.selected).toEqual([]) + }) + + it('a pile card picked goes with a new table or a cleared one', () => { + const { result, receive } = mount() + const pile = { stage: 'draw' as const, discardPile: [c('6♥'), c('2♠')] } + receive({ gameJoined: { view: view(pile) } }) + act(() => result.current.pickDownTo(c('6♥'))) + receive({ gameJoined: { view: view(pile) } }) + expect(result.current.downTo).toBeNull() + act(() => result.current.pickDownTo(c('6♥'))) + act(() => result.current.clear()) + expect(result.current.downTo).toBeNull() + }) + + it('a take-down with no pile card picked, or one the pile no longer holds, sends nothing', () => { + const { result, receive, move } = mount() + receive({ gameJoined: { view: view({ stage: 'draw', discardPile: [c('6♥'), c('2♠')] }) } }) + act(() => result.current.takeDownLayOff(0)) + act(() => result.current.pickDownTo(c('5♥'))) + act(() => result.current.takeDownLayOff(0)) + expect(move).not.toHaveBeenCalled() }) it('a knock names the one card thrown', () => { @@ -159,9 +207,10 @@ describe('useRummyTable', () => { first.unmount() 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') + // An order no longer offered reads as the default. + window.localStorage.setItem('rummy.order', 'melds') + expect(mount().result.current.order).toBe('suit') }) it('a turn and the table starting are the felt’s to show; another table opening is a toast', () => { diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts index fc796c3..f64d425 100644 --- a/src/hooks/useRummyTable.ts +++ b/src/hooks/useRummyTable.ts @@ -11,7 +11,7 @@ import { safeLocalStorage } from '@/utils/safeLocalStorage' // How the viewer likes the hand laid out outlives the table. const ORDER_KEY = 'rummy.order' -const ORDERS: HandOrder[] = ['suit', 'rank', 'melds'] +const ORDERS: HandOrder[] = ['suit', 'rank'] const storedOrder = (): HandOrder => ORDERS.find(order => order === safeLocalStorage.get(ORDER_KEY)) ?? 'suit' @@ -25,9 +25,15 @@ export interface RummyTableActions { // finished one is already gone from the hub. playAgain: () => void drawStock: () => void - // The top of the discard pile, or every card from the top down to - // `downTo`, which must then be played before the turn ends. - drawDiscard: (downTo?: Card) => void + // The top of the discard pile. + drawDiscard: () => void + // A card deeper in the discard pile to take it down to, picked or put + // back; the take-down is sent by one of the two below. + pickDownTo: (card: Card) => void + // The pile down to the picked card, melded with the selected cards. + takeDownMeld: () => void + // The pile down to the picked card, laid off onto this table meld. + takeDownLayOff: (meldIndex: number) => void // Gin: turn the upcard down. pass: () => void // Selection is by card, not by slot: the hand is shown sorted, and a @@ -51,6 +57,9 @@ export interface UseRummyTable extends RummyTableActions { // the hub answers: the next view clears them, and a refusal leaves them // picked so the move can be fixed rather than rebuilt. selected: string[] + // The face of the discard pile card picked to take the pile down to; + // cleared like the selection. + downTo: string | null order: HandOrder // A table has been asked for and not yet arrived. opening: boolean @@ -79,6 +88,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa const [view, setView] = useState(null) const [ended, setEnded] = useState(null) const [selected, setSelected] = useState([]) + const [downTo, setDownTo] = useState(null) const [opening, setOpening] = useState(false) const [dealing, setDealing] = useState(false) const [order, setOrderState] = useState(storedOrder) @@ -87,6 +97,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa setView(null) setEnded(null) setSelected([]) + setDownTo(null) setOpening(false) setDealing(false) }, []) @@ -101,6 +112,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa setView(update.gameJoined.view) setEnded(null) setSelected([]) + setDownTo(null) setOpening(false) setDealing(false) return @@ -108,6 +120,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa if (update.gameState) { setView(update.gameState.view) setSelected([]) + setDownTo(null) setDealing(false) return } @@ -160,7 +173,19 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa }, [clear, move, onLeft, view]) const drawStock = useCallback(() => move('drawStock'), [move]) - const drawDiscard = useCallback((downTo?: Card) => (downTo === undefined ? move('drawDiscard') : move('drawDiscard', { card: downTo })), [move]) + const drawDiscard = useCallback(() => move('drawDiscard'), [move]) + // The hand cards picked were for the pile card: they go when it is put + // back or another is picked. + const pickDownTo = useCallback( + (card: Card) => { + const picked = face(card) + setSelected([]) + setDownTo(downTo === picked ? null : picked) + }, + [downTo] + ) + // The picked pile card, read out of the pile now on screen. + const downToCard = useCallback((): Card | null => view?.discardPile?.find(card => face(card) === downTo) ?? null, [downTo, view]) const pass = useCallback(() => move('pass'), [move]) const toggleCard = useCallback((card: Card) => { @@ -196,6 +221,22 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa [move, selectedCards] ) + const takeDownMeld = useCallback(() => { + const card = downToCard() + const cards = selectedCards() + if (card === null || cards === null) return + move('takeDown', { card, cards }) + }, [downToCard, move, selectedCards]) + + const takeDownLayOff = useCallback( + (meldIndex: number) => { + const card = downToCard() + if (card === null) return + move('takeDown', { card, meldIndex }) + }, + [downToCard, move] + ) + const discardSelected = useCallback(() => { const cards = selectedCards() if (cards === null || cards.length !== 1) return @@ -217,6 +258,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa view, ended, selected, + downTo, order, opening, dealing, @@ -231,6 +273,9 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa playAgain, drawStock, drawDiscard, + pickDownTo, + takeDownMeld, + takeDownLayOff, pass, toggleCard, meldSelected,