From 5bf9dac1adfc57af3fe45b79af1b7587ddd13ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:18:25 +0000 Subject: [PATCH 1/3] rummy: the whole discard pile, take-downs, a score notepad --- src/apps/rummy/__tests__/rules.test.ts | 5 + .../rummy/components/RummyTable.module.css | 115 ++++++++++++++++++ src/apps/rummy/components/RummyTable.tsx | 37 +++++- src/apps/rummy/components/ScoreSheet.tsx | 61 ++++++++++ .../components/__tests__/RummyTable.test.tsx | 59 +++++++++ src/apps/rummy/rules.ts | 7 +- src/apps/rummy/wire.ts | 25 +++- src/hooks/__tests__/useRummyTable.test.tsx | 7 ++ src/hooks/useRummyTable.ts | 6 +- 9 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 src/apps/rummy/components/ScoreSheet.tsx diff --git a/src/apps/rummy/__tests__/rules.test.ts b/src/apps/rummy/__tests__/rules.test.ts index b0a910e..c5f9922 100644 --- a/src/apps/rummy/__tests__/rules.test.ts +++ b/src/apps/rummy/__tests__/rules.test.ts @@ -113,6 +113,11 @@ 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', () => { diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css index 4ef2444..832e34f 100644 --- a/src/apps/rummy/components/RummyTable.module.css +++ b/src/apps/rummy/components/RummyTable.module.css @@ -240,3 +240,118 @@ button.meld { .ginHands td { text-align: left; } + +/* The score sheet: a notepad pinned in the felt's top corner, outside the + round of the felt, ruled like paper and written in a hand. */ +.notepad { + position: absolute; + top: -0.5rem; + left: -2.5rem; + z-index: 2; + width: max-content; + padding: 0.5rem 0.6rem 0.6rem 1.4rem; + border-radius: 2px; + background-color: #fdf8e2; + background-image: + linear-gradient(to right, transparent 1rem, rgba(214, 69, 65, 0.55) 1rem, rgba(214, 69, 65, 0.55) calc(1rem + 1px), transparent calc(1rem + 1px)), + repeating-linear-gradient(to bottom, transparent 0, transparent 1.35rem, rgba(80, 120, 190, 0.28) 1.35rem, rgba(80, 120, 190, 0.28) calc(1.35rem + 1px)); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35); + transform: rotate(-2deg); + color: #2a2a33; + font-family: 'Bradley Hand', 'Segoe Print', 'Comic Sans MS', cursive; + font-size: 0.85rem; + line-height: 1.35rem; +} + +.notepadSheet { + border-collapse: collapse; +} + +.notepadSheet th, +.notepadSheet td { + padding: 0 0.35rem; + text-align: right; + font-weight: normal; + white-space: nowrap; +} + +.notepadSheet thead th { + max-width: 4.5rem; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 700; +} + +.notepadSheet tfoot td, +.notepadSheet tfoot th { + border-top: 2px solid #2a2a33; + font-weight: 700; +} + +.notepadDraw { + text-align: center; + font-style: italic; + opacity: 0.7; +} + +@media (max-width: 600px) { + .notepad { + left: 0; + padding-left: 1.1rem; + font-size: 0.7rem; + line-height: 1.1rem; + } + + .notepadSheet thead th { + max-width: 3rem; + } +} + +/* The discard pile spread: each card peeks from under the next, showing + enough to read its corner. A long pile tightens to fit, down to a + corner's width, and scrolls past that. --gaps is the card count less + one, set by the table. */ +.discardSpread { + --card-w: 3.2rem; + --spread-max: 16rem; + --step: max(0.8rem, min(1.65rem, calc((var(--spread-max) - var(--card-w)) / var(--gaps, 1)))); + display: flex; + align-items: flex-end; + max-width: var(--spread-max); + overflow-x: auto; + padding-top: 0.7rem; +} + +.discardSpread > * { + flex-shrink: 0; +} + +.discardSpread > * + * { + margin-left: calc(var(--step) - var(--card-w)); +} + +@media (max-width: 600px), (max-height: 900px) { + .discardSpread { + --card-w: 2.6rem; + } +} + +@media (max-width: 600px) { + .discardSpread { + --spread-max: 11rem; + } +} + +/* 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. */ +.discardSpread > .drawable { + position: relative; + z-index: 1; + transform: translateY(-0.6rem); +} diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index d55f5db..7e333d6 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -26,6 +26,7 @@ import { CardBack, CardFace } from '@/apps/castle/components/Cards' import { clockOf, fromViewer } from '@/apps/castle/seating' import felt from '@/apps/castle/components/CastleTable.module.css' import styles from './RummyTable.module.css' +import ScoreSheet from './ScoreSheet' // The rummy table from the viewer's chair, seen from above, on castle's // felt: the viewer at 6 o'clock with their moves under their hand, the @@ -190,6 +191,10 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R // while it has a top. const discardDrawable = view.canDrawDiscard ?? view.discardTop !== undefined const takeable = (drawing || upcard) && discardDrawable + // 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)) : [] // 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) @@ -208,6 +213,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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)}.` + if (deeper.length > 0) return `Draw from the stock, take the ${face(view.discardTop)}, or take the pile down to a lit card.` return `Draw from the stock, or take the ${face(view.discardTop)}.` } if (laying && gin) { @@ -215,6 +221,9 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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 && 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 (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.' @@ -401,7 +410,8 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R // 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 notes = [taken ? 'just taken' : '', laysOff ? 'fits a meld' : ''].filter(Boolean) + 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) return ( 0 ? [face(card), ...notes].join(', ') : undefined} toggle={mine && laying ? selected.includes(face(card)) : undefined} @@ -584,6 +594,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R document.body )}
+ {dealt && } {fromViewer(view.players, playerId).map((seat, i, all) => renderSeat(seat, clockOf(all.length, i)))} {view.phase !== 'waiting' && dealt && (
@@ -602,7 +613,25 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R )} {view.stockCount}
-
+
+ {pile.slice(0, -1).map(card => + deeper.includes(face(card)) ? ( + table.drawDiscard(card)) : undefined} + /> + ) : ( + + ) + )} {view.discardTop === undefined ? (
discard
) : ( @@ -610,7 +639,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R card={view.discardTop} className={takeable ? styles.drawable : ''} label={takeable ? `take ${face(view.discardTop)} from the discard pile` : `${face(view.discardTop)} on the discard pile`} - onClick={takeable && connected ? thenHand(table.drawDiscard) : undefined} + onClick={takeable && connected ? thenHand(() => table.drawDiscard()) : undefined} /> )} {view.discardCount > 0 && {view.discardCount}} diff --git a/src/apps/rummy/components/ScoreSheet.tsx b/src/apps/rummy/components/ScoreSheet.tsx new file mode 100644 index 0000000..1682eb2 --- /dev/null +++ b/src/apps/rummy/components/ScoreSheet.tsx @@ -0,0 +1,61 @@ +import type { RummyView } from '../wire' +import styles from './RummyTable.module.css' + +// The table's running score as a notepad pinned in the felt's corner: a +// column a player, a line a deal with the points in its winner's column, +// and the totals under a rule. A player who has left keeps their column +// for the deals they won. + +export interface ScoreSheetProps { + view: RummyView + playerId: string +} + +const ScoreSheet = ({ view, playerId }: ScoreSheetProps) => { + const lines = view.scoreSheet ?? [] + const columns = [...view.standings.map(standing => standing.playerId)] + for (const line of lines) { + if (line.winner !== undefined && !columns.includes(line.winner)) columns.push(line.winner) + } + const total = (id: string) => lines.reduce((sum, line) => sum + (line.winner === id ? line.points : 0), 0) + return ( + + ) +} + +export default ScoreSheet diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 5f56165..862723e 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -576,6 +576,65 @@ describe('RummyTable', () => { }) + it('the discard pile lies spread, every card face up', () => { + mountWith(view({ currentPlayerId: 'bob', discardPile: [c('4♦'), c('9♣'), c('Q♠')] })) + const pile = within(screen.getByRole('group', { name: 'discard pile' })) + expect(pile.getAllByRole('img').map(card => card.getAttribute('aria-label'))).toEqual([ + '4♦ in the discard pile', + '9♣ in the discard pile', + 'Q♠ on the discard pile' + ]) + }) + + 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() + 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() + }) + + 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: [] })) + expect(screen.queryByRole('img', { name: '4♦ in the discard pile' })).toBeNull() + expect(screen.getByRole('button', { name: 'take Q♠ from the discard pile' })).toBeDefined() + }) + + it('keeps score on a notepad: a line a deal, the winner’s column scored, totals under', () => { + mountWith( + view({ + dealNumber: 3, + standings: [ + { playerId: 'alice', handsWon: 1, points: 12 }, + { playerId: 'bob', handsWon: 1, points: 43 } + ], + scoreSheet: [ + { variant: '7-card', winner: 'bob', points: 43 }, + { variant: 'gin', points: 0 }, + { variant: '7-card', winner: 'alice', points: 12 } + ] + }) + ) + const sheet = within(screen.getByRole('table', { name: 'Score sheet' })) + const rows = sheet.getAllByRole('row').map(row => row.textContent) + expect(rows).toEqual(['#youbob', '1—43', '2draw', '312—', 'Total1243']) + }) + + it('no notepad before the first deal', () => { + mountWith(between({ dealer: 'alice', dealNumber: 0 })) + expect(screen.queryByRole('table', { name: 'Score sheet' })).toBeNull() + }) + 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 f2aa352..cdc34da 100644 --- a/src/apps/rummy/rules.ts +++ b/src/apps/rummy/rules.ts @@ -150,9 +150,12 @@ export function deadwood(hand: Card[]): number { return hand.reduce((sum, card) => sum + cardPoints(card), 0) } -// The card just taken from the discard pile may not go straight back, -// unless it is all the hand has left. +// 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. 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 } diff --git a/src/apps/rummy/wire.ts b/src/apps/rummy/wire.ts index 637f131..3371914 100644 --- a/src/apps/rummy/wire.ts +++ b/src/apps/rummy/wire.ts @@ -50,6 +50,17 @@ export interface RummyLastMove { export interface RummyStanding { playerId: string handsWon: number + // The running score: what the seat's won deals scored. Absent from a hub + // older than the score sheet. + points?: number +} + +// A line of the table's score sheet: one deal played to its end. No winner +// for a gin draw; the winner may have left since. +export interface RummyScoreLine { + variant: string + winner?: string + points: number } export interface RummyScore { @@ -120,11 +131,22 @@ export interface RummyView { canDrawDiscard?: boolean discardCount: number 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 melds: RummyMeld[] lastMove?: RummyLastMove + // Every deal played to its end, in order. + scoreSheet?: RummyScoreLine[] } // The table broke up, below two seats: the hands each seat still at it @@ -155,7 +177,8 @@ export interface RummyMovePayloads { chooseVariant: { variant: string } pass: undefined drawStock: undefined - drawDiscard: undefined + // The deepest card to take; none, the top. + drawDiscard: { card: Card } | undefined 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 c8e51bc..87dee81 100644 --- a/src/hooks/__tests__/useRummyTable.test.tsx +++ b/src/hooks/__tests__/useRummyTable.test.tsx @@ -134,6 +134,13 @@ describe('useRummyTable', () => { expect(move.mock.calls).toEqual([['drawStock'], ['drawDiscard'], ['pass']]) }) + it('taking the pile down names the deepest card', () => { + 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♥') }]]) + }) + it('a knock names the one card thrown', () => { const { result, receive, move } = mount() receive({ gameJoined: { view: view() } }) diff --git a/src/hooks/useRummyTable.ts b/src/hooks/useRummyTable.ts index 62eb2e0..fc796c3 100644 --- a/src/hooks/useRummyTable.ts +++ b/src/hooks/useRummyTable.ts @@ -25,7 +25,9 @@ export interface RummyTableActions { // finished one is already gone from the hub. playAgain: () => void drawStock: () => void - drawDiscard: () => 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 // Gin: turn the upcard down. pass: () => void // Selection is by card, not by slot: the hand is shown sorted, and a @@ -158,7 +160,7 @@ export const useRummyTable = ({ playerId, move, showNotice, onLeft }: UseRummyTa }, [clear, move, onLeft, view]) const drawStock = useCallback(() => move('drawStock'), [move]) - const drawDiscard = useCallback(() => move('drawDiscard'), [move]) + const drawDiscard = useCallback((downTo?: Card) => (downTo === undefined ? move('drawDiscard') : move('drawDiscard', { card: downTo })), [move]) const pass = useCallback(() => move('pass'), [move]) const toggleCard = useCallback((card: Card) => { From 53c3e3bd7b4a79d582a8d9d84f8f8d7f88afce40 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:35:18 +0000 Subject: [PATCH 2/3] rummy: spread opens on top; notepad pages; must-play hints Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MXWdcUNjGezMzyuCkaMMLg --- .../rummy/components/RummyTable.module.css | 31 +++++-- src/apps/rummy/components/RummyTable.tsx | 80 ++++++++++--------- src/apps/rummy/components/ScoreSheet.tsx | 20 +++-- .../components/__tests__/RummyTable.test.tsx | 51 ++++++++++++ 4 files changed, 130 insertions(+), 52 deletions(-) diff --git a/src/apps/rummy/components/RummyTable.module.css b/src/apps/rummy/components/RummyTable.module.css index 832e34f..04b5537 100644 --- a/src/apps/rummy/components/RummyTable.module.css +++ b/src/apps/rummy/components/RummyTable.module.css @@ -275,10 +275,12 @@ button.meld { white-space: nowrap; } -.notepadSheet thead th { +.notepadName { + display: inline-block; max-width: 4.5rem; overflow: hidden; text-overflow: ellipsis; + vertical-align: bottom; font-weight: 700; } @@ -288,21 +290,27 @@ button.meld { font-weight: 700; } -.notepadDraw { +.notepadSheet td.notepadDraw { text-align: center; font-style: italic; opacity: 0.7; } -@media (max-width: 600px) { +/* Where the felt fills the table there is no room left of it. */ +@media (max-width: 960px) { .notepad { left: 0; + } +} + +@media (max-width: 600px) { + .notepad { padding-left: 1.1rem; font-size: 0.7rem; line-height: 1.1rem; } - .notepadSheet thead th { + .notepadName { max-width: 3rem; } } @@ -318,8 +326,9 @@ button.meld { display: flex; align-items: flex-end; max-width: var(--spread-max); - overflow-x: auto; - padding-top: 0.7rem; + overflow: auto hidden; + /* Room inside the scroll box for a raised card and its outline. */ + padding: 0.8rem 0.3rem 0.3rem; } .discardSpread > * { @@ -342,6 +351,13 @@ button.meld { } } +/* The short window's cards (2.25rem). */ +@media (max-height: 700px) and (min-width: 601px) { + .discardSpread { + --card-w: 2.25rem; + } +} + /* The card the pile was taken down to, still owed to the table. */ .owed { outline: 2px solid #e8674a; @@ -350,7 +366,8 @@ button.meld { /* 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. */ -.discardSpread > .drawable { +.discardSpread > .drawable, +.discardSpread > .drawable:enabled:hover { position: relative; z-index: 1; transform: translateY(-0.6rem); diff --git a/src/apps/rummy/components/RummyTable.tsx b/src/apps/rummy/components/RummyTable.tsx index 7e333d6..f8b50fa 100644 --- a/src/apps/rummy/components/RummyTable.tsx +++ b/src/apps/rummy/components/RummyTable.tsx @@ -201,6 +201,14 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R // 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 + // A pile too long for the spread scrolls; it opens on the top card, the + // one usually taken. + const spreadRef = useRef(null) + useEffect(() => { + const spread = spreadRef.current + if (spread !== null) spread.scrollLeft = spread.scrollWidth + }, [pile.length]) + const hint = (() => { if (view.phase === 'waiting') return view.players.length < 2 ? 'Waiting for a second seat.' : '' if (between && dealer !== undefined) { @@ -211,9 +219,10 @@ 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.' - 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)}.` - if (deeper.length > 0) return `Draw from the stock, take the ${face(view.discardTop)}, or take the pile down to a lit card.` + const down = deeper.length > 0 ? ', or take the pile down to a lit card' : '' + 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}.` return `Draw from the stock, or take the ${face(view.discardTop)}.` } if (laying && gin) { @@ -221,13 +230,13 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R 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 && 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 (laying) { - if (single !== null && fitting.length > 0) return `Tap a lit meld to lay off ${face(single)}, or discard it.` - if (picked.length === 2) return 'Pick three or more to meld, or one to lay off or discard.' + if (single !== null && fitting.length > 0) return `Tap a lit meld to lay off ${face(single)}${view.mustPlay === undefined ? ', 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.' } @@ -330,7 +339,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R - {single !== null && !discardable && ( + {single !== null && view.takenDiscard !== undefined && face(single) === face(view.takenDiscard) && !discardable && (

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

)} @@ -345,7 +354,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R - {single !== null && !discardable && ( + {single !== null && view.takenDiscard !== undefined && face(single) === face(view.takenDiscard) && !discardable && (

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

)} @@ -613,35 +622,32 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R )} {view.stockCount}
-
- {pile.slice(0, -1).map(card => - deeper.includes(face(card)) ? ( +
+
+ {pile.slice(0, -1).map(card => + deeper.includes(face(card)) ? ( + table.drawDiscard(card)) : undefined} + /> + ) : ( + + ) + )} + {view.discardTop === undefined ? ( +
discard
+ ) : ( table.drawDiscard(card)) : undefined} + card={view.discardTop} + className={takeable ? styles.drawable : ''} + label={takeable ? `take ${face(view.discardTop)} from the discard pile` : `${face(view.discardTop)} on the discard pile`} + onClick={takeable && connected ? thenHand(() => table.drawDiscard()) : undefined} /> - ) : ( - - ) - )} - {view.discardTop === undefined ? ( -
discard
- ) : ( - table.drawDiscard()) : undefined} - /> - )} + )} +
{view.discardCount > 0 && {view.discardCount}}
diff --git a/src/apps/rummy/components/ScoreSheet.tsx b/src/apps/rummy/components/ScoreSheet.tsx index 1682eb2..dab0b4a 100644 --- a/src/apps/rummy/components/ScoreSheet.tsx +++ b/src/apps/rummy/components/ScoreSheet.tsx @@ -4,7 +4,10 @@ import styles from './RummyTable.module.css' // The table's running score as a notepad pinned in the felt's corner: a // column a player, a line a deal with the points in its winner's column, // and the totals under a rule. A player who has left keeps their column -// for the deals they won. +// for the deals they won. The page holds the last few deals, so a long +// table's sheet never grows over the seats; the totals count them all. + +const PAGE = 5 export interface ScoreSheetProps { view: RummyView @@ -17,30 +20,31 @@ const ScoreSheet = ({ view, playerId }: ScoreSheetProps) => { for (const line of lines) { if (line.winner !== undefined && !columns.includes(line.winner)) columns.push(line.winner) } + const first = Math.max(0, lines.length - PAGE) const total = (id: string) => lines.reduce((sum, line) => sum + (line.winner === id ? line.points : 0), 0) return ( - +
) } diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index 862723e..faef48e 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -604,6 +604,40 @@ describe('RummyTable', () => { expect(myHandGroup().getByRole('button', { name: /^9♥, must be played/ })).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('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() + cleanup() + mountWith(view({ mustPlay: c('9♥') }), { selected: ['K♦', '7♣', '2♠'] }) + expect(screen.getByText('Those cards 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('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) + const left = vi.spyOn(HTMLElement.prototype, 'scrollLeft', 'set').mockImplementation(value => { + scrolled.push(value) + }) + try { + const pile = ['2♦', '3♦', '4♦', '5♦', '6♦', '7♦', '8♦', '9♦', '10♦', 'J♦', 'Q♦', 'K♦', '2♥', '3♥', '4♥', '5♥', '6♥', '7♥', '8♥', 'Q♠'].map(c) + mountWith(view({ currentPlayerId: 'bob', discardPile: pile, discardCount: pile.length })) + expect(scrolled).toContain(900) + } finally { + width.mockRestore() + left.mockRestore() + } + }) + 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: [] })) expect(screen.queryByRole('img', { name: '4♦ in the discard pile' })).toBeNull() @@ -630,6 +664,23 @@ describe('RummyTable', () => { expect(rows).toEqual(['#youbob', '1—43', '2draw', '312—', 'Total1243']) }) + it('the notepad is the score sheet, not a landmark, and an empty cell says nothing', () => { + mountWith(view({ scoreSheet: [{ variant: '7-card', winner: 'bob', points: 43 }] })) + expect(screen.queryByRole('complementary')).toBeNull() + const sheet = within(screen.getByRole('table', { name: 'Score sheet' })) + expect(sheet.getAllByRole('cell').map(cell => cell.textContent)).toContain('—') + expect(screen.getByText('—').getAttribute('aria-hidden')).toBe('true') + }) + + it('the notepad keeps the last five deals on its page, and totals them all', () => { + const scoreSheet = [1, 2, 3, 4, 5, 6, 7].map(points => ({ variant: '7-card', winner: 'bob', points })) + mountWith(view({ dealNumber: 7, scoreSheet })) + const rows = within(screen.getByRole('table', { name: 'Score sheet' })) + .getAllByRole('row') + .map(row => row.textContent) + expect(rows).toEqual(['#youbob', '3—3', '4—4', '5—5', '6—6', '7—7', 'Total028']) + }) + it('no notepad before the first deal', () => { mountWith(between({ dealer: 'alice', dealNumber: 0 })) expect(screen.queryByRole('table', { name: 'Score sheet' })).toBeNull() From cf577ba5bf0486c6075f9bf20f6a09f3c50b6c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:11:27 +0000 Subject: [PATCH 3/3] rummy: pin departed winners, owed marker, top-only hint Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MXWdcUNjGezMzyuCkaMMLg --- .../rummy/components/__tests__/RummyTable.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/apps/rummy/components/__tests__/RummyTable.test.tsx b/src/apps/rummy/components/__tests__/RummyTable.test.tsx index faef48e..5ea98cb 100644 --- a/src/apps/rummy/components/__tests__/RummyTable.test.tsx +++ b/src/apps/rummy/components/__tests__/RummyTable.test.tsx @@ -602,6 +602,12 @@ describe('RummyTable', () => { 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) + }) + + 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♠')] })) + 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', () => { @@ -681,6 +687,14 @@ describe('RummyTable', () => { expect(rows).toEqual(['#youbob', '3—3', '4—4', '5—5', '6—6', '7—7', 'Total028']) }) + it('a winner who has left keeps their column and total', () => { + mountWith(view({ scoreSheet: [{ variant: '7-card', winner: 'carol', points: 20 }] })) + const rows = within(screen.getByRole('table', { name: 'Score sheet' })) + .getAllByRole('row') + .map(row => row.textContent) + expect(rows).toEqual(['#youbobcarol', '1——20', 'Total0020']) + }) + it('no notepad before the first deal', () => { mountWith(between({ dealer: 'alice', dealNumber: 0 })) expect(screen.queryByRole('table', { name: 'Score sheet' })).toBeNull()