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
5 changes: 5 additions & 0 deletions src/apps/rummy/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
132 changes: 132 additions & 0 deletions src/apps/rummy/components/RummyTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,135 @@ 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;
}

.notepadName {
display: inline-block;
max-width: 4.5rem;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
font-weight: 700;
}

.notepadSheet tfoot td,
.notepadSheet tfoot th {
border-top: 2px solid #2a2a33;
font-weight: 700;
}

.notepadSheet td.notepadDraw {
text-align: center;
font-style: italic;
opacity: 0.7;
}

/* 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;
}

.notepadName {
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: auto hidden;
/* Room inside the scroll box for a raised card and its outline. */
padding: 0.8rem 0.3rem 0.3rem;
}

.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 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;
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,
.discardSpread > .drawable:enabled:hover {
position: relative;
z-index: 1;
transform: translateY(-0.6rem);
}
73 changes: 54 additions & 19 deletions src/apps/rummy/components/RummyTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -190,12 +191,24 @@ 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)
// 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<HTMLDivElement>(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) {
Expand All @@ -206,8 +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)}.`
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) {
Expand All @@ -216,9 +231,12 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
return 'Discard, or knock with 10 or less deadwood left.'
}
if (laying) {
if (single !== null && fitting.length > 0) return `Tap a lit meld to lay off ${face(single)}, or discard it.`
if (picked.length === 2) return 'Pick three or more to meld, or one to lay off or discard.'
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.'
}
Expand Down Expand Up @@ -321,7 +339,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
<button type="button" className={mayKnock ? felt.primary : felt.secondary} onClick={thenHand(table.knockSelected)} disabled={!mayKnock || !connected}>
{single === null ? 'Knock' : `Knock on ${face(single)}`}
</button>
{single !== null && !discardable && (
{single !== null && view.takenDiscard !== undefined && face(single) === face(view.takenDiscard) && !discardable && (
<p className={felt.muted}>You just took {face(single)}: it can’t go straight back.</p>
)}
</>
Expand All @@ -336,7 +354,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
<button type="button" className={felt.secondary} onClick={thenHand(table.discardSelected)} disabled={!discardable || !connected}>
{single === null ? 'Discard' : `Discard ${face(single)}`}
</button>
{single !== null && !discardable && (
{single !== null && view.takenDiscard !== undefined && face(single) === face(view.takenDiscard) && !discardable && (
<p className={felt.muted}>You just took {face(single)}: it can’t go straight back.</p>
)}
</>
Expand Down Expand Up @@ -401,7 +419,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 (
<span
key={mine && card !== null ? `${face(card)}:${entered ? handMark.gen : 0}` : i}
Expand All @@ -413,7 +432,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
) : (
<CardFace
card={card}
className={`${entered ? felt.entered : ''} ${taken ? styles.taken : ''} ${laysOff ? styles.laysOff : ''}`}
className={`${entered ? felt.entered : ''} ${taken ? styles.taken : ''} ${laysOff ? styles.laysOff : ''} ${owed ? styles.owed : ''}`}
style={entered ? ({ '--i': handMark.entered.indexOf(i) } as CSSProperties) : undefined}
label={notes.length > 0 ? [face(card), ...notes].join(', ') : undefined}
toggle={mine && laying ? selected.includes(face(card)) : undefined}
Expand Down Expand Up @@ -584,6 +603,7 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
document.body
)}
<div className={felt.ring}>
{dealt && <ScoreSheet view={view} playerId={playerId} />}
{fromViewer(view.players, playerId).map((seat, i, all) => renderSeat(seat, clockOf(all.length, i)))}
{view.phase !== 'waiting' && dealt && (
<section className={`${felt.pile} ${styles.middle}`} aria-label="table">
Expand All @@ -602,17 +622,32 @@ const RummyTable = ({ playerId, connected, view, table, away = [], children }: R
)}
<span className={felt.count}>{view.stockCount}</span>
</div>
<div className={felt.pileCards}>
{view.discardTop === undefined ? (
<div className={felt.emptyPile}>discard</div>
) : (
<CardFace
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}
/>
)}
<div className={felt.pileCards} role="group" aria-label="discard pile">
<div ref={spreadRef} className={gin ? '' : styles.discardSpread} style={{ '--gaps': Math.max(pile.length - 1, 1) } as CSSProperties}>
{pile.slice(0, -1).map(card =>
deeper.includes(face(card)) ? (
<CardFace
key={face(card)}
card={card}
className={styles.drawable}
label={`take the pile down to ${face(card)}`}
onClick={connected ? thenHand(() => table.drawDiscard(card)) : undefined}
/>
) : (
<CardFace key={face(card)} card={card} label={`${face(card)} in the discard pile`} />
)
)}
{view.discardTop === undefined ? (
<div className={felt.emptyPile}>discard</div>
) : (
<CardFace
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}
/>
)}
</div>
{view.discardCount > 0 && <span className={felt.count}>{view.discardCount}</span>}
</div>
</div>
Expand Down
65 changes: 65 additions & 0 deletions src/apps/rummy/components/ScoreSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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. 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
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 first = Math.max(0, lines.length - PAGE)
const total = (id: string) => lines.reduce((sum, line) => sum + (line.winner === id ? line.points : 0), 0)
return (
<div className={styles.notepad}>
<table className={styles.notepadSheet} aria-label="Score sheet">
<thead>
<tr>
<th scope="col">#</th>
{columns.map(id => (
<th key={id} scope="col" title={id}>
<span className={styles.notepadName}>{id === playerId ? 'you' : id}</span>
</th>
))}
</tr>
</thead>
<tbody>
{lines.slice(first).map((line, i) => (
<tr key={first + i}>
<th scope="row">{first + i + 1}</th>
{line.winner === undefined ? (
<td colSpan={columns.length} className={styles.notepadDraw}>
draw
</td>
) : (
columns.map(id => <td key={id}>{id === line.winner ? line.points : <span aria-hidden="true">—</span>}</td>)
)}
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
{columns.map(id => (
<td key={id}>{total(id)}</td>
))}
</tr>
</tfoot>
</table>
</div>
)
}

export default ScoreSheet
Loading
Loading