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
165 changes: 0 additions & 165 deletions src/apps/rummy/__tests__/arrange_corpus.json

This file was deleted.

51 changes: 5 additions & 46 deletions src/apps/rummy/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
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 @@ -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♥'
)
Expand Down Expand Up @@ -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♣') },
Expand All @@ -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', () => {
Expand Down
49 changes: 25 additions & 24 deletions src/apps/rummy/components/RummyTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 > * {
Expand Down Expand Up @@ -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;
}
Loading
Loading