diff --git a/src/apps/castle/__tests__/rules.test.ts b/src/apps/castle/__tests__/rules.test.ts index a1d9031..b7288d4 100644 --- a/src/apps/castle/__tests__/rules.test.ts +++ b/src/apps/castle/__tests__/rules.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { cardsOf, describeEnding, describeLastPlay, describePile, headlineOf, rowInPlay, standingOf, toggleSelection } from '../rules' +import { cardsOf, describeEnding, describeLastPlay, describePile, enteredSince, headlineOf, rowInPlay, standingOf, toggleSelection } from '../rules' import type { CastlePlayer, CastleView } from '../wire' const seat = (over: Partial = {}): CastlePlayer => ({ @@ -82,13 +82,11 @@ describe('describeLastPlay', () => { }) describe('describePile', () => { - it('names the price the next play must match', () => { - expect(describePile(view())).toBe('Empty pile: anything goes') - expect(describePile(view({ run: [{ rank: '8', suit: '♥' }], pileCount: 3 }))).toBe( - '8♥ on top: play one or more of 8 or higher' - ) + it('names the price the next play must match, and nothing the table already shows', () => { + expect(describePile(view())).toBe('Anything goes') + expect(describePile(view({ run: [{ rank: '8', suit: '♥' }], pileCount: 3 }))).toBe('Play 8 or higher') expect(describePile(view({ run: [{ rank: '8', suit: '♠' }, { rank: '8', suit: '♥' }], pileCount: 3 }))).toBe( - 'two 8s on top: play two or more of 8 or higher' + 'Play two or more, 8 or higher' ) // A queen on a queen: two show, but one king answers, since the // count to match is the last play's. @@ -100,11 +98,11 @@ describe('describePile', () => { lastPlay: { playerId: 'bob', cards: [{ rank: 'Q', suit: '♥' }], burned: false, pickedUp: false } }) ) - ).toBe('two Qs on top: play one or more of Q or higher') + ).toBe('Play Q or higher') // Three 3s must not read as arithmetic. expect( describePile(view({ run: [{ rank: '3', suit: '♠' }, { rank: '3', suit: '♦' }, { rank: '3', suit: '♥' }], pileCount: 3 })) - ).toBe('three 3s on top: play three or more of 3 or higher') + ).toBe('Play three or more, 3 or higher') }) }) @@ -133,3 +131,19 @@ describe('standingOf and headlineOf', () => { expect(headlineOf({ finished: [] }, 'alice')).toBe('The table broke up') }) }) + +describe('enteredSince', () => { + it('names the cards that were not there, counting duplicates as a multiset', () => { + const hand = [ + { rank: 'K', suit: '♣' }, + { rank: 'K', suit: '♣' }, + { rank: '3', suit: '♦' } + ] + // One K♣ was there: the second is new, the first is not. + expect(enteredSince(['K♣'], hand)).toEqual([1, 2]) + expect(enteredSince(['K♣', 'K♣', '3♦'], hand)).toEqual([]) + expect(enteredSince([], hand)).toEqual([0, 1, 2]) + // Order changed, nothing arrived. + expect(enteredSince(['3♦', 'K♣', 'K♣'], hand)).toEqual([]) + }) +}) diff --git a/src/apps/castle/components/CastleTable.module.css b/src/apps/castle/components/CastleTable.module.css index b1d419d..cf907f9 100644 --- a/src/apps/castle/components/CastleTable.module.css +++ b/src/apps/castle/components/CastleTable.module.css @@ -120,32 +120,132 @@ left: 50%; top: 50%; transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; padding: 0.75rem; border-radius: 0.75rem; background: rgba(0, 0, 0, 0.25); max-width: 16rem; + text-align: center; } -.pileCards { +/* The draw pile beside the pile: two stacks with their counts on them, + which is all the numbers the middle needs to say. */ +.piles { display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: center; + align-items: flex-end; + gap: 0.75rem; +} + +.drawPile, +.pileCards { + position: relative; +} + +.drawPile > .card { + opacity: 0.9; +} + +.drawn > .card { + opacity: 0.25; +} + +.count { + position: absolute; + right: -0.4rem; + bottom: -0.4rem; + min-width: 1.4rem; + padding: 0 0.3rem; + border-radius: 0.7rem; + background: #f0c96a; + color: #3a2a00; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.4rem; text-align: center; } -.pileText { - display: flex; - flex-direction: column; - gap: 0.25rem; +.drawPile > .count { + right: auto; + left: -0.4rem; +} + +.drawn > .count { + background: rgba(255, 255, 255, 0.3); + color: white; +} + +.price { + margin: 0; + font-weight: 600; } -.pileText p { +/* The last play: in, a moment to read, and gone. A pick-up stays for + longer and shows the card that failed, because that is the moment a + handful of cards arrives and needs explaining. */ +.lastPlaySlot { margin: 0; } +.lastPlaySlot:empty { + display: none; +} + .lastPlay { + display: flex; + align-items: center; + gap: 0.5rem; color: #f0c96a; + --linger: 3s; + animation: lastPlay var(--linger) ease-in forwards; +} + +.pickedUp { + --linger: 6s; + padding: 0.3rem 0.5rem; + border-radius: 0.5rem; + background: rgba(240, 201, 106, 0.15); +} + +/* Small beside its sentence; the phone's own card size would win the + tie, so this one names its parent. */ +.lastPlay .flipped { + flex: 0 0 auto; + width: 2.2rem; + height: 3.1rem; + font-size: 0.85rem; + animation: flipOver 0.6s ease-out both; +} + +@keyframes lastPlay { + 0% { + opacity: 0; + transform: translateY(4px); + } + 8% { + opacity: 1; + transform: none; + } + 85% { + opacity: 1; + } + 100% { + opacity: 0; + /* Gone for a screen reader too, not just faded; the moment leaves + the tree when the animation ends. */ + visibility: hidden; + } +} + +@keyframes flipOver { + from { + transform: rotateY(90deg); + } + to { + transform: none; + } } .run { @@ -198,6 +298,14 @@ position: relative; } +/* The other chairs are further away: their cards are drawn smaller, + layout and all, so the viewer's own hand has the screen. What they + show — how many in hand, which castle stacks still stand, what is + face up — reads at this size; nothing at their chairs is tapped. */ +.seat:not(.mine) .seatFrame { + zoom: 0.72; +} + .seatCards { display: flex; flex-direction: column; @@ -249,6 +357,13 @@ left: 50%; bottom: 1rem; transform: translateX(-50%); + /* Capped so a big hand scrolls inside the chair rather than widening it. */ + max-width: calc(100% - 2rem); +} + +.seatFrame, +.seatCards { + max-width: 100%; } .seat[data-clock='12'] { @@ -329,33 +444,89 @@ down and to the right so the back's corner shows under it. */ top: 0.8rem; } -/* The hand, fanned around a point below the cards. */ +/* The hand, fanned around a point below the cards. Wider than the + chair, it scrolls — by finger, or dragged by mouse — and the last card + in view is cut off, which is how a hand says there is more. The + track sits in the middle while it fits and starts at the left once + it does not. */ .hand { display: flex; - justify-content: center; - align-items: flex-end; + max-width: 100%; min-height: 5.2rem; - padding: 0.5rem 0.75rem 0; + /* Room at the sides for the end cards, which lean out past the track + that lays them out; the left of a scroller cannot be scrolled to. */ + padding: 0.5rem 1.5rem 0.6rem; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x proximity; + overscroll-behavior-x: contain; + scrollbar-width: none; + cursor: grab; +} + +.hand::-webkit-scrollbar { + display: none; +} + +.hand:active { + cursor: grabbing; +} + +.fan { + display: flex; + align-items: flex-end; + margin: 0 auto; } /* Overlap grows with the hand (the seat sets --overlap), but never past - what leaves a card an edge to tap. */ + what leaves a card its corner index and a thumb's width to tap. */ .fanSlot { - display: block; - margin-left: calc(-1 * min(var(--overlap, 1rem), var(--overlap-max, 2.2rem))); + position: relative; + flex: 0 0 auto; + margin-left: calc(-1 * min(var(--overlap, 1rem), var(--overlap-max, 1.4rem))); transform-origin: 50% 160%; + scroll-snap-align: center; } .fanSlot:first-child { margin-left: 0; } -.fanSlot:hover, -.fanSlot:focus-within { +/* Pointed at or tabbed to, a card comes to the front. Not a selected + one: raised in place, its gold edge and index show, and the neighbour + it would otherwise cover keeps its index for the next pick. A tapped + card keeps hover and focus on a phone, so neither counts there. */ +.fanSlot:has(:focus-visible) { z-index: 1; } +@media (hover: hover) { + .fanSlot:hover { + z-index: 1; + } +} + +/* A card that just arrived slides up into place, one after another. + Filled backwards only: once landed the card is a card, and its + selected and hovered lifts are its own again. */ +.entered { + animation: cardIn 0.35s ease-out backwards; + animation-delay: calc(var(--i, 0) * 45ms); +} + +@keyframes cardIn { + from { + opacity: 0; + transform: translateY(14px) scale(0.92); + } + to { + opacity: 1; + transform: none; + } +} + .card { + position: relative; width: 3.2rem; height: 4.6rem; border-radius: 0.4rem; @@ -363,13 +534,47 @@ down and to the right so the back's corner shows under it. */ background: white; color: #1a1a1a; display: flex; - flex-direction: column; align-items: center; justify-content: center; font-size: 1.1rem; - line-height: 1.1; + line-height: 1; padding: 0; cursor: default; + /* An edge, so two white cards do not read as one white shape. */ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12); +} + +/* A card lying on another casts onto it. */ +.fanSlot + .fanSlot .card, +.runSlot + .runSlot .card { + box-shadow: + -3px 0 6px rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(0, 0, 0, 0.12); +} + +/* The marks scale with the card's font size, so the small cards and the + flipped one in the last-play line carry the same face. */ +.index { + position: absolute; + top: 0.15em; + left: 0.25em; + display: flex; + flex-direction: column; + align-items: center; + font-size: 0.62em; + line-height: 1.05; +} + +.indexBottom { + top: auto; + left: auto; + right: 0.25em; + bottom: 0.15em; + transform: rotate(180deg); +} + +.pip { + font-size: 1.35em; } .card:enabled { @@ -496,6 +701,7 @@ down and to the right so the back's corner shows under it. */ font-size: 0.85rem; } + .card { width: 2.6rem; height: 3.8rem; @@ -503,7 +709,12 @@ down and to the right so the back's corner shows under it. */ } .fanSlot { - --overlap-max: 1.8rem; + --overlap-max: 1rem; + } + + /* Smaller cards: the card under the top one still shows its rank. */ + .runSlot { + margin-left: -1.3rem; } .emptyPile { @@ -639,7 +850,25 @@ down and to the right so the back's corner shows under it. */ @media (prefers-reduced-motion: reduce) { .endingOverlay, - .endingCard { + .endingCard, + .entered, + .lastPlay .flipped { animation: none; } + + /* Still goes, still without moving. */ + .lastPlay { + animation: lastPlayStill var(--linger) ease-in forwards; + } +} + +@keyframes lastPlayStill { + 0%, + 85% { + opacity: 1; + } + 100% { + opacity: 0; + visibility: hidden; + } } diff --git a/src/apps/castle/components/CastleTable.tsx b/src/apps/castle/components/CastleTable.tsx index 6c4ebdf..79257a1 100644 --- a/src/apps/castle/components/CastleTable.tsx +++ b/src/apps/castle/components/CastleTable.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import type { CSSProperties, KeyboardEvent, ReactNode } from 'react' +import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent, ReactNode } from 'react' import type { Standing } from '../rules' import type { CastleTableActions } from '@/hooks/useCastleTable' -import type { Card, CastleGameEnded, CastlePlayer, CastleView } from '../wire' -import { cardsOf, describeEnding, describeLastPlay, describePile, face, headlineOf, isRed, rowInPlay, seatOf, standingOf } from '../rules' +import type { Card, CastleGameEnded, CastleLastPlay, CastlePlayer, CastleView } from '../wire' +import { cardsOf, describeEnding, describeLastPlay, describePile, enteredSince, face, headlineOf, isRed, rowInPlay, seatOf, standingOf } from '../rules' import { clockOf, fromViewer } from '../seating' import styles from './CastleTable.module.css' @@ -23,22 +23,38 @@ interface CardFaceProps { toggle?: boolean label?: string className?: string + style?: CSSProperties } -const CardFace = ({ card, onClick, toggle, label, className = '' }: CardFaceProps) => { +// A card's face: the index in the top-left and, turned round, the +// bottom-right, the way a real card carries it — so a card mostly under +// its neighbour still says what it is — and its suit in the middle. +const CardFaceMarks = ({ card }: { card: Card }) => ( + <> + + {card.rank} + {card.suit} + + {card.suit} + + {card.rank} + {card.suit} + + +) + +const CardFace = ({ card, onClick, toggle, label, className = '', style }: CardFaceProps) => { const classes = `${styles.card} ${isRed(card) ? styles.red : ''} ${toggle ? styles.selected : ''} ${className}` if (onClick === undefined) { return ( - - {card.rank} - {card.suit} + + ) } return ( - ) } @@ -67,6 +83,22 @@ export interface CastleTableProps { // Another seat's hand is backs: past this many, the count says the rest. const SHOWN_BACKS = 6 +// The whole fan spans at most this many degrees, however many cards: +// three cards sit five degrees apart, fourteen closer, so the ends of a +// big hand still face the player and stay inside the hand's own edges. +const FAN_SPREAD = 30 +const FAN_STEP = 5 + +// A mouse drag that moved this far was a scroll, not a tap on a card. +const DRAG_SLOP = 6 + +// The play as a string: the key that restarts the last-play moment when +// a new one lands. A view repeats the last play until the next replaces +// it, and a play that reads the same as the last — a pick-up by choice +// twice running — is the same moment, and does not. +const playSignature = (play: CastleLastPlay): string => + `${play.playerId}:${play.cards.map(face).join(',')}:${play.burned}:${play.pickedUp}` + const ENDING_EMOJI: Record = { won: '🏆', lost: '😤', other: '🤝' } const CastleTable = ({ playerId, connected, view, table, children }: CastleTableProps) => { @@ -86,9 +118,87 @@ const CastleTable = ({ playerId, connected, view, table, children }: CastleTable // hands can be looked over. Keyed by table, so the next one's ending // arrives in front again. const [endingRead, setEndingRead] = useState(null) + // The last play whose moment has faded: gone from the layout, not just + // from view, so the pile does not keep an empty line — or a card-high + // hole after a pick-up — until the next play. + const [faded, setFaded] = useState(null) const playAgainRef = useRef(null) const endingRef = useRef(null) + // Cards that just entered the viewer's hand slide in, so a pick-up + // reads as the pile arriving rather than the hand having changed. + // Held until the hand changes again, so a re-render mid-slide does + // not cut it short. A new table's deal is not an arrival: nothing was + // there before it to arrive into. + const myHand = seatOf(view, playerId)?.hand ?? [] + const handSig = `${view.gameId}:${myHand.map(face).join(',')}` + // The generation is part of an arriving card's key: a draw-back lands + // at the same index every turn, and a node React keeps does not run + // its animation again. + const [handMark, setHandMark] = useState<{ sig: string; faces: string[]; entered: number[]; gen: number }>({ + sig: '', + faces: [], + entered: [], + gen: 0 + }) + if (handMark.sig !== handSig) { + setHandMark({ + sig: handSig, + faces: myHand.map(face), + entered: handMark.sig.startsWith(`${view.gameId}:`) ? enteredSince(handMark.faces, myHand) : [], + gen: handMark.gen + 1 + }) + } + + // The arrival is for seeing: a wide hand is brought to its first new + // card, which on a hand that fits moves nothing. + useEffect(() => { + if (handMark.entered.length === 0) return + handRef.current?.querySelector(`.${styles.entered}`)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }) + }, [handMark]) + + // A hand wider than its chair scrolls. Touch scrolls it natively; a + // mouse drags it, and a drag that moved the hand is not a tap on the + // card it started on. One that moved nothing — a hand that fits — is. + const handRef = useRef(null) + const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null) + const dragged = useRef(false) + const onHandPointerDown = (event: PointerEvent) => { + if (event.pointerType !== 'mouse' || handRef.current === null) return + drag.current = { x: event.clientX, left: handRef.current.scrollLeft, moved: false } + } + const onHandPointerMove = (event: PointerEvent) => { + if (drag.current === null || handRef.current === null) return + if ((event.buttons & 1) === 0) { + // Not the primary button — another one, which ends in a context + // menu and never a click, or one that came up where the hand did + // not see it. A drag that would not end in a click would leave + // the next tap eaten. + drag.current = null + return + } + const dx = event.clientX - drag.current.x + if (Math.abs(dx) <= DRAG_SLOP) return + // Keeps the drag when the pointer leaves the hand. Taken only once + // it is a drag: a captured press retargets its click to the hand, + // and the card under it never hears the tap. Not every DOM has it + // (jsdom's does not). + handRef.current.setPointerCapture?.(event.pointerId) + handRef.current.scrollLeft = drag.current.left - dx + // The browser clamps a hand that fits back to where it was. + if (handRef.current.scrollLeft !== drag.current.left) drag.current.moved = true + } + const onHandPointerUp = () => { + dragged.current = drag.current?.moved ?? false + drag.current = null + } + const onHandClickCapture = (event: MouseEvent) => { + if (!dragged.current) return + dragged.current = false + event.stopPropagation() + event.preventDefault() + } + // gameEnded lands right behind the final view; without it there is // no result to show yet. const showEnding = view.phase === 'ended' && ended !== null && endingRead !== view.gameId @@ -270,39 +380,61 @@ const CastleTable = ({ playerId, connected, view, table, children }: CastleTable })} {/* The hand, fanned: faces for the viewer's own (and everyone's - once the game ends), backs for the rest. */} - {/* A hand that grew on pick-ups fans tighter, so it stays a hand. */} -
- {(seat.hand.length > 0 ? seat.hand : Array.from({ length: Math.min(seat.handCount, SHOWN_BACKS) }, () => null)).map((card, i, all) => { + once the game ends), backs for the rest. A hand wider than + the chair scrolls; the overlap is capped so every card keeps + its corner index in view. */} + {(() => { + const shown = seat.hand.length > 0 ? seat.hand : Array.from({ length: Math.min(seat.handCount, SHOWN_BACKS) }, () => null) + const step = Math.min(FAN_STEP, FAN_SPREAD / Math.max(shown.length, 1)) + const slotKey = (i: number) => (mine && handMark.entered.includes(i) ? `${i}:${handMark.gen}` : i) + const renderCard = (card: Card | null, i: number) => { const picking = card !== null && mine && arranging const playable = card !== null && mine && myTurn && myRow === 'hand' - const angle = (i - (all.length - 1) / 2) * 5 - return ( - - {card === null ? ( - - ) : ( - setPendingSwap(swapFrom === i ? null : { gameId: view.gameId, card }) - : playable && connected - ? () => table.toggleCard(i) - : undefined - } - /> - )} - + const entered = mine && handMark.entered.includes(i) + return card === null ? ( + + ) : ( + setPendingSwap(swapFrom === i ? null : { gameId: view.gameId, card }) + : playable && connected + ? () => table.toggleCard(i) + : undefined + } + /> ) - })} -
+ } + return ( +
+
+ {shown.map((card, i, all) => { + const angle = (i - (all.length - 1) / 2) * step + return ( + + {renderCard(card, i)} + + ) + })} +
+
+ ) + })()} {mine &&
{actions}
} @@ -375,29 +507,64 @@ const CastleTable = ({ playerId, connected, view, table, children }: CastleTable {fromViewer(view.players, playerId).map((seat, i, all) => renderSeat(seat, clockOf(all.length, i)))} {(view.phase === 'playing' || view.phase === 'ended') && (
-
- {view.run.length === 0 ? ( -
empty
- ) : ( - // The run on top, tightly fanned: a pair of sevens reads as a pair. -
- {view.run.map((card, i) => ( - - - - ))} -
- )} -
-

{describePile(view)}

-

- {view.pileCount} on the pile · {view.drawPileCount} to draw -

- {view.lastPlay !== undefined &&

{describeLastPlay(view.lastPlay, playerId)}

} +
+ {/* The draw pile, as a thing rather than a count in prose. */} +
+ + {view.drawPileCount} +
+
+ {view.run.length === 0 ? ( +
empty
+ ) : ( + // The run on top, tightly fanned: a pair of sevens reads as a pair. +
+ {view.run.map((card, i) => ( + + + + ))} +
+ )} + {view.pileCount > 0 && {view.pileCount}}
+ {/* The price is the mover's to read. Off turn the run shows the + rank to beat; how many to play arrives with the turn, since a + run can be taller than the play that topped it. */} + {myTurn &&

{describePile(view)}

} + {/* The last play, for a moment: a pick-up shows the card that + did not play and stays longer, since a handful of cards + just arrived and this is why. */} + {/* One live region for the table's life, so a new play is a + change to it rather than a region appearing; the moment + itself is the keyed child. */} +

+ {view.lastPlay !== undefined && `${view.gameId}:${playSignature(view.lastPlay)}` !== faded && ( + { + if (event.target === event.currentTarget && view.lastPlay !== undefined) { + setFaded(`${view.gameId}:${playSignature(view.lastPlay)}`) + } + }} + > + {view.lastPlay.pickedUp && view.lastPlay.cards[0] !== undefined && ( + + )} + {describeLastPlay(view.lastPlay, playerId)} + + )} +

- )} + )} {children} diff --git a/src/apps/castle/components/__tests__/CastleTable.test.tsx b/src/apps/castle/components/__tests__/CastleTable.test.tsx index cdd938d..ebd3ca2 100644 --- a/src/apps/castle/components/__tests__/CastleTable.test.tsx +++ b/src/apps/castle/components/__tests__/CastleTable.test.tsx @@ -60,6 +60,10 @@ const table = (over: Partial = {}): CastleTableProps[ ...over }) +// jsdom has no AnimationEvent, and React then listens for the +// webkit-prefixed end rather than the unprefixed one. +const endAnimation = (el: Element) => fireEvent(el, new Event('webkitAnimationEnd', { bubbles: true })) + const mountWith = (v: CastleView, over: Partial = {}, connected = true) => { const t = table(over) const rendered = render() @@ -163,7 +167,12 @@ describe('CastleTable', () => { it('on turn: hand cards select, play sends the selection, pick-up only without a play', () => { const { t } = mountWith(view(), { selected: [0, 1] }) - expect(screen.getByText('two 8s on top: play one or more of 8 or higher')).toBeDefined() + // The price, in a line; the counts, on the piles; the run, as cards. + expect(screen.getByText('Play 8 or higher')).toBeDefined() + expect(screen.getByRole('img', { name: '30 to draw' })).toBeDefined() + // A group, not an image: an image's children are decoration, and + // the run inside is the rank to beat. + expect(screen.getByRole('group', { name: '2 on the pile' })).toBeDefined() const run = within(screen.getByRole('group', { name: 'run on top' })) expect(run.getByRole('img', { name: '8♠' })).toBeDefined() expect(run.getByRole('img', { name: '8♥' })).toBeDefined() @@ -186,10 +195,131 @@ describe('CastleTable', () => { expect(t.pickUp).toHaveBeenCalled() cleanup() mountWith(view({ pileCount: 0, run: [], lastPlay: undefined })) - expect(screen.getByText('Empty pile: anything goes')).toBeDefined() + expect(screen.getByText('Anything goes')).toBeDefined() expect(screen.getByRole('button', { name: 'Pick up the pile' })).toBeDisabled() }) + it('off turn the price is not yours to read', () => { + mountWith(view({ currentPlayerId: 'bob' })) + expect(screen.queryByText('Play 8 or higher')).toBeNull() + // The piles and the run still say where the table stands. + // A group, not an image: an image's children are decoration, and + // the run inside is the rank to beat. + expect(screen.getByRole('group', { name: '2 on the pile' })).toBeDefined() + expect(screen.getByRole('group', { name: 'run on top' })).toBeDefined() + }) + + it('a failed flip shows the card that did not play, with the pile it brought', () => { + const flipped = view({ + pileCount: 0, + run: [], + lastPlay: { playerId: 'alice', cards: [{ rank: '3', suit: '♦' }], burned: false, pickedUp: true } + }) + mountWith(flipped) + const told = screen.getByText('You flipped 3♦ and picked up the pile').parentElement as HTMLElement + expect(told.className).toContain('pickedUp') + expect(within(told).getByRole('img', { name: '3♦' })).toBeDefined() + }) + + it('the last play is one live region that changes, not a new one each play', () => { + const t = table() + const { rerender } = render() + const region = screen.getByText('bob played 8♥').closest('[role="status"]') + expect(region).not.toBeNull() + rerender( + + ) + expect(screen.getByText('You played 9♣').closest('[role="status"]')).toBe(region) + }) + + it('a faded last play leaves the pile, and the next one is back', () => { + const t = table() + const { rerender } = render() + const moment = screen.getByText('bob played 8♥').parentElement as HTMLElement + const region = moment.parentElement as HTMLElement + endAnimation(moment) + expect(screen.queryByText('bob played 8♥')).toBeNull() + expect(region.childElementCount).toBe(0) + const next = view({ lastPlay: { playerId: 'alice', cards: [{ rank: '9', suit: '♣' }], burned: false, pickedUp: false } }) + rerender() + expect(screen.getByText('You played 9♣')).toBeDefined() + }) + + it('the flipped card finishing its turn does not take the moment with it', () => { + mountWith(view({ pileCount: 0, run: [], lastPlay: { playerId: 'alice', cards: [{ rank: '3', suit: '♦' }], burned: false, pickedUp: true } })) + const told = screen.getByText('You flipped 3♦ and picked up the pile') + endAnimation(within(told.parentElement as HTMLElement).getByRole('img', { name: '3♦' })) + expect(screen.getByText('You flipped 3♦ and picked up the pile')).toBeDefined() + }) + + it('an arrival is brought into view; a deal is not', () => { + const scroll = vi.fn() + const proto = Element.prototype as Element & { scrollIntoView?: typeof scroll } + const had = proto.scrollIntoView + proto.scrollIntoView = scroll + try { + const t = table() + const { rerender } = render() + expect(scroll).not.toHaveBeenCalled() + const after = view() + after.players[0] = { ...after.players[0], handCount: 5, hand: [...myHand, { rank: '3', suit: '♣' }, { rank: '3', suit: '♦' }] } + rerender() + expect(scroll).toHaveBeenCalledTimes(1) + const hand = within(screen.getByRole('group', { name: 'Your hand' })) + expect(scroll.mock.instances[0]).toBe(hand.getByRole('button', { name: '3♣' })) + } finally { + proto.scrollIntoView = had + } + }) + + it('cards that just arrived slide in, and the ones that were there do not', () => { + const t = table() + const before = view() + const { rerender } = render() + // The deal is not an arrival: nothing was there before it. + const hand0 = within(screen.getByRole('group', { name: 'Your hand' })) + expect(hand0.getByRole('button', { name: 'K♦' }).className).not.toContain('entered') + const after = view() + after.players[0] = { + ...after.players[0], + handCount: 5, + hand: [...myHand, { rank: '3', suit: '♣' }, { rank: '3', suit: '♦' }] + } + rerender() + const hand = within(screen.getByRole('group', { name: 'Your hand' })) + expect(hand.getByRole('button', { name: '3♣' }).className).toContain('entered') + expect(hand.getByRole('button', { name: '3♦' }).className).toContain('entered') + expect(hand.getByRole('button', { name: 'K♦' }).className).not.toContain('entered') + // Staggered in the order they arrived. + expect(hand.getByRole('button', { name: '3♦' }).style.getPropertyValue('--i')).toBe('1') + + // A draw-back lands at the same index every turn; each one is a + // fresh node, or the slide would run once per game. + const draw = (rank: string) => { + const v = view() + v.players[0] = { ...v.players[0], hand: [{ rank: 'K', suit: '♣' }, { rank: 'Q', suit: '♠' }, { rank, suit: '♥' }] } + rerender() + return within(screen.getByRole('group', { name: 'Your hand' })).getByRole('button', { name: `${rank}♥` }) + } + const first = draw('5') + expect(first.className).toContain('entered') + const second = draw('6') + expect(second.className).toContain('entered') + expect(second).not.toBe(first) + + // Another table's deal, the same hand or not, is a deal. + const next = view({ gameId: 'G2' }) + next.players[0] = { ...next.players[0], hand: [{ rank: '3', suit: '♣' }, { rank: '9', suit: '♠' }], handCount: 2 } + rerender() + const dealt = within(screen.getByRole('group', { name: 'Your hand' })) + expect(dealt.getByRole('button', { name: '9♠' }).className).not.toContain('entered') + }) + it('off turn nothing is offered', () => { mountWith(view({ currentPlayerId: 'bob' })) expect(screen.queryByRole('button', { name: 'Q♠' })).toBeNull() @@ -254,13 +384,89 @@ describe('CastleTable', () => { expect(live.container.querySelector('[data-phase="playing"]')).not.toBeNull() }) - it('a hand that grew fans tighter', () => { + it('a hand that grew fans tighter, and every hand is the one fan', () => { const big = view() - big.players[0] = { ...big.players[0], handCount: 10, hand: Array.from({ length: 10 }, (_, i) => ({ rank: String(i + 2), suit: '♣' })) } + big.players[0] = { ...big.players[0], handCount: 7, hand: Array.from({ length: 7 }, (_, i) => ({ rank: String(i + 2), suit: '♣' })) } mountWith(big) const overlap = (name: RegExp) => screen.getByRole('group', { name }).style.getPropertyValue('--overlap') - expect(overlap(/Your hand/)).toBe('1.8rem') + expect(overlap(/Your hand/)).toBe('1.2rem') expect(overlap(/bob's hand/)).toBe('1rem') + // The spread is bounded at thirty degrees: seven cards a little + // under five apart, fourteen closer, so the ends still face the + // player. + const slots = (name: RegExp) => Array.from(screen.getByRole('group', { name }).querySelectorAll('[class*="fanSlot"]')) + expect(slots(/Your hand/)[0].style.transform).toContain(`rotate(${(-3 * 30) / 7}deg)`) + cleanup() + const bigger = view() + bigger.players[0] = { ...bigger.players[0], handCount: 14, hand: Array.from({ length: 14 }, (_, i) => ({ rank: String((i % 9) + 2), suit: i < 9 ? '♣' : '♦' })) } + mountWith(bigger) + expect(slots(/Your hand/)[0].style.transform).toContain(`rotate(${(-6.5 * 30) / 14}deg)`) + expect(slots(/Your hand/)).toHaveLength(14) + }) + + it('a hand wider than its chair scrolls: a drag is not a tap, a tap is', () => { + const big = view() + big.players[0] = { ...big.players[0], handCount: 12, hand: Array.from({ length: 12 }, (_, i) => ({ rank: String((i % 9) + 2), suit: i < 9 ? '♣' : '♦' })) } + const { t } = mountWith(big) + const hand = screen.getByRole('group', { name: 'Your hand' }) + expect(within(hand).getAllByRole('button')).toHaveLength(12) + // Capturing the pointer on the press would retarget its click to the + // hand, and the card under it would never hear the tap. + const capture = vi.fn() + ;(hand as HTMLElement & { setPointerCapture: typeof capture }).setPointerCapture = capture + // A tap is a tap. + fireEvent.click(within(hand).getByRole('button', { name: '5♣' })) + expect(t.toggleCard).toHaveBeenCalledWith(3) + // A mouse drag that moved the hand scrolls it, and the card it + // started on is not played. + fireEvent.pointerDown(hand, { pointerType: 'mouse', pointerId: 1, clientX: 100, button: 0, buttons: 1 }) + expect(capture).not.toHaveBeenCalled() + fireEvent.pointerMove(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40, buttons: 1 }) + expect(hand.scrollLeft).toBe(60) + expect(capture).toHaveBeenCalledWith(1) + fireEvent.pointerUp(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40 }) + fireEvent.click(within(hand).getByRole('button', { name: '6♣' })) + expect(t.toggleCard).toHaveBeenCalledTimes(1) + // The next tap is a tap again. + fireEvent.click(within(hand).getByRole('button', { name: '6♣' })) + expect(t.toggleCard).toHaveBeenLastCalledWith(4) + // A touch never drags here: the hand scrolls itself under a finger. + fireEvent.pointerDown(hand, { pointerType: 'touch', pointerId: 2, clientX: 100 }) + fireEvent.pointerMove(hand, { pointerType: 'touch', pointerId: 2, clientX: 40 }) + fireEvent.pointerUp(hand, { pointerType: 'touch', pointerId: 2, clientX: 40 }) + fireEvent.click(within(hand).getByRole('button', { name: '7♣' })) + expect(t.toggleCard).toHaveBeenLastCalledWith(5) + // A press that did not move is not a drag. + fireEvent.pointerDown(hand, { pointerType: 'mouse', pointerId: 1, clientX: 100, buttons: 1 }) + fireEvent.pointerMove(hand, { pointerType: 'mouse', pointerId: 1, clientX: 103, buttons: 1 }) + fireEvent.pointerUp(hand, { pointerType: 'mouse', pointerId: 1, clientX: 103 }) + fireEvent.click(within(hand).getByRole('button', { name: '8♣' })) + expect(t.toggleCard).toHaveBeenLastCalledWith(6) + // A drag on any button but the first ends in a context menu and + // never a click, and does not eat the next tap. + fireEvent.pointerDown(hand, { pointerType: 'mouse', pointerId: 1, clientX: 100, button: 2, buttons: 2 }) + fireEvent.pointerMove(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40, buttons: 2 }) + fireEvent.pointerUp(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40, button: 2 }) + fireEvent.click(within(hand).getByRole('button', { name: '9♣' })) + expect(t.toggleCard).toHaveBeenLastCalledWith(7) + // A button that came up where the hand could not see it ends the + // drag: bare movement does not scroll. + hand.scrollLeft = 0 + fireEvent.pointerDown(hand, { pointerType: 'mouse', pointerId: 1, clientX: 100, buttons: 1 }) + fireEvent.pointerMove(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40, buttons: 0 }) + expect(hand.scrollLeft).toBe(0) + }) + + it('a hand that fits does not scroll, so a drag across it is a tap', () => { + const { t } = mountWith(view()) + const hand = screen.getByRole('group', { name: 'Your hand' }) + // The browser clamps scrollLeft to 0 when nothing overflows. + Object.defineProperty(hand, 'scrollLeft', { get: () => 0, set: () => {}, configurable: true }) + fireEvent.pointerDown(hand, { pointerType: 'mouse', pointerId: 1, clientX: 100, buttons: 1 }) + fireEvent.pointerMove(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40, buttons: 1 }) + fireEvent.pointerUp(hand, { pointerType: 'mouse', pointerId: 1, clientX: 40 }) + fireEvent.click(within(hand).getByRole('button', { name: 'Q♠' })) + expect(t.toggleCard).toHaveBeenCalledWith(2) }) it('the ending arrives in front, and waves away to the final hands', () => { diff --git a/src/apps/castle/rules.ts b/src/apps/castle/rules.ts index 0304245..fd0cc3c 100644 --- a/src/apps/castle/rules.ts +++ b/src/apps/castle/rules.ts @@ -32,6 +32,20 @@ export function face(card: Card): string { return `${card.rank}${card.suit}` } +// Which cards of the hand were not in it last time: the draw-back, or +// the pile just picked up. Matched as a multiset by face, so a second +// K♣ is new only if there was not one already. +export function enteredSince(previous: string[], hand: Card[]): number[] { + const left = [...previous] + const entered: number[] = [] + hand.forEach((card, i) => { + const at = left.indexOf(face(card)) + if (at < 0) entered.push(i) + else left.splice(at, 1) + }) + return entered +} + export function isRed(card: Card): boolean { return card.suit === '♥' || card.suit === '♦' } @@ -52,15 +66,15 @@ export function describeLastPlay(play: CastleLastPlay, viewer: string): string { const COUNTS = ['none', 'one', 'two', 'three', 'four'] -// The pile as a price: the count to match is the last play's, the run -// on top is what shows (and what a four of a kind completes). Counts -// are words, so a run of three 3s does not read as arithmetic. +// The pile as a price, for the seat that has to pay it: the count to +// match is the last play's, the rank the top's. The run itself is on the +// table, so the line does not repeat it. Counts are words, so a run of +// three 3s does not read as arithmetic. export function describePile(view: CastleView): string { const top = view.run[view.run.length - 1] - if (top === undefined) return 'Empty pile: anything goes' - const shown = view.run.length > 1 ? `${COUNTS[view.run.length] ?? view.run.length} ${top.rank}s` : face(top) + if (top === undefined) return 'Anything goes' const price = view.lastPlay !== undefined && view.lastPlay.cards.length > 0 ? view.lastPlay.cards.length : view.run.length - return `${shown} on top: play ${COUNTS[price] ?? price} or more of ${top.rank} or higher` + return price > 1 ? `Play ${COUNTS[price] ?? price} or more, ${top.rank} or higher` : `Play ${top.rank} or higher` } // How a finished game reads from one chair. diff --git a/src/hooks/__tests__/useCastleTable.test.tsx b/src/hooks/__tests__/useCastleTable.test.tsx index e27b67e..ec99c11 100644 --- a/src/hooks/__tests__/useCastleTable.test.tsx +++ b/src/hooks/__tests__/useCastleTable.test.tsx @@ -125,6 +125,16 @@ describe('useCastleTable', () => { expect(result.current.selected).toEqual([]) }) + it('only your own turn is a toast; the felt shows the rest', () => { + const move = vi.fn() + const showNotice = vi.fn() + const { result } = renderHook(() => useCastleTable({ playerId: 'alice', move, showNotice, onLeft: vi.fn() })) + act(() => result.current.handleUpdate({ turnChanged: { playerId: 'bob' } })) + expect(showNotice).not.toHaveBeenCalled() + act(() => result.current.handleUpdate({ turnChanged: { playerId: 'alice' } })) + expect(showNotice).toHaveBeenCalledWith('Your turn') + }) + it('play again opens another table, and the ending goes with the old one', () => { const { result, receive, move } = mount() receive({ gameState: { view: view({ phase: 'ended' }) } }) diff --git a/src/hooks/__tests__/useGolfTable.test.tsx b/src/hooks/__tests__/useGolfTable.test.tsx index c1da316..a82d0cf 100644 --- a/src/hooks/__tests__/useGolfTable.test.tsx +++ b/src/hooks/__tests__/useGolfTable.test.tsx @@ -147,8 +147,9 @@ describe('useGolfTable', () => { expect(showNotice).toHaveBeenLastCalledWith('Game started! Each player can peek at 2 cards.') receive({ turnChanged: { playerId: 'alice' } }) expect(showNotice).toHaveBeenLastCalledWith('Your turn') + // Another seat's turn is the table's to show, not a toast's. receive({ turnChanged: { playerId: 'bob' } }) - expect(showNotice).toHaveBeenLastCalledWith("It's bob's turn") + expect(showNotice).toHaveBeenLastCalledWith('Your turn') receive({ playerKnocked: { playerId: 'bob' } }) expect(showNotice).toHaveBeenLastCalledWith('bob has knocked! Last round!') }) diff --git a/src/hooks/useCastleTable.ts b/src/hooks/useCastleTable.ts index 86044ff..4aceeaf 100644 --- a/src/hooks/useCastleTable.ts +++ b/src/hooks/useCastleTable.ts @@ -84,7 +84,10 @@ export const useCastleTable = ({ playerId, move, showNotice, onLeft }: UseCastle return } if (update.turnChanged) { - showNotice(update.turnChanged.playerId === playerId ? 'Your turn' : `${update.turnChanged.playerId} to play`) + // Only your own turn interrupts. The felt already lights the + // seat on turn, and a toast for every other turn lands over the + // hand on a phone. + if (update.turnChanged.playerId === playerId) showNotice('Your turn') return } if (update.gameEnded) { diff --git a/src/hooks/useGolfTable.ts b/src/hooks/useGolfTable.ts index 08c8216..341fbd6 100644 --- a/src/hooks/useGolfTable.ts +++ b/src/hooks/useGolfTable.ts @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react' import type { GameState } from '@/types/golf' import type { GolfMoveName, GolfUpdate, GolfView } from '@/apps/golf/wire' import { mapGameView } from '@/apps/golf/wire' -import { GAME_STARTED, gameOverMessage, knockedMessage, turnMessage } from '@/utils/golfNotifications' +import { GAME_STARTED, gameOverMessage, knockedMessage } from '@/utils/golfNotifications' import { usePeekCountdown } from './usePeekCountdown' // A golf table as the wire sends it, in the UI's model, over the lobby's @@ -92,7 +92,8 @@ export const useGolfTable = ({ playerId, move, showNotice, onLeft }: UseGolfTabl return } if (update.turnChanged) { - showNotice(update.turnChanged.playerId === playerId ? 'Your turn' : turnMessage(update.turnChanged.playerId)) + // Only your own turn interrupts; the table shows whose it is. + if (update.turnChanged.playerId === playerId) showNotice('Your turn') return } if (update.playerKnocked) { diff --git a/src/utils/golfNotifications.ts b/src/utils/golfNotifications.ts index c6335f3..f39c676 100644 --- a/src/utils/golfNotifications.ts +++ b/src/utils/golfNotifications.ts @@ -3,6 +3,5 @@ export const GAME_STARTED = 'Game started! Each player can peek at 2 cards.' -export const turnMessage = (player: string) => `It's ${player}'s turn` export const knockedMessage = (player: string) => `${player} has knocked! Last round!` export const gameOverMessage = (winner: string) => `Game over! Winner: ${winner}`