Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions WORKING_AGREEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
165 changes: 165 additions & 0 deletions src/apps/rummy/__tests__/arrange_corpus.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
100 changes: 95 additions & 5 deletions src/apps/rummy/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
arrangedMeld,
bestArrangement,
canDeal,
canDiscard,
deadwood,
describeEnding,
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
Expand Down Expand Up @@ -98,6 +101,7 @@ describe('canDiscard', () => {
players: [],
stockCount: 0,
canDrawStock: true,
canDrawDiscard: true,
discardCount: 0,
melds: [],
...over
Expand All @@ -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')
Expand All @@ -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.')
})
Expand Down Expand Up @@ -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)
Expand All @@ -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!')
})
})
23 changes: 23 additions & 0 deletions src/apps/rummy/components/RummyTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading