Keep won tricks in front of the player who took them

A trick slid off towards its winner and stopped existing. Where it went
was the only record that it had been won at all, and by the third trick
of a hand there was nothing on the table to say who was sitting on what
— which on a real table is the most-glanced-at thing in the game, and
the whole reason cards are gathered rather than binned.

Each player now has a face-down pile in front of them: one card back per
trick, because a trick is squared into a single bundle and put down, and
from above that is what you see. The piles sit beside each player's own
card, one place clockwise along their edge, which is both where a player
would actually put them and the one spot on each edge nothing else was
using. Wizard seats three to six so its piles go half a seat clockwise
round the same ring the cards are played on.

The count follows the cards, not the rules. A trick is counted the
instant its last card is played, which is a beat before the gather and
two before the cards arrive; reading the state directly grew the
winner's pile while the trick was still lying in the middle of the
table, and the cards then slid over and landed on a pile that had
already counted them. rememberArrivedTricks holds the count back until
the sweep has actually run.

The sweep changes to suit its new destination. A gathered card is
squared down towards the size of the bundle it is joining, and stays
whole until the last stretch instead of fading the whole way over: it
now vanishes at the same moment the pile grows by one, and that swap is
only invisible if there was something there to swap.

Kaiser needed a per-seat trick count, which it had never had — it scores
in points, so no rule wanted one. It is defaulted for the wire and
carries a test, because a field no rule depends on is a field that can
quietly stop being maintained. Hearts already keeps the cards themselves
and counts them by the deal.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0132V9gE6DF9itz2z3Hn7BK9
This commit is contained in:
Claude
2026-08-13 22:10:04 +00:00
parent a7427b0cb5
commit cba66906bb
9 changed files with 350 additions and 9 deletions
@@ -92,6 +92,14 @@ data class KaiserState(
val lastTrickWinner: Int?,
/** Points taken this hand, per team. */
val handPoints: List<Int>,
/**
* Tricks taken this hand, per seat. Kaiser scores in points rather than
* tricks, so nothing in the rules needs this — it is what the table draws
* in front of each player, and a player who has taken four tricks and no
* counters has still taken four tricks. Defaulted so a client on an older
* build can still read a state from the wire.
*/
val tricksWon: List<Int> = List(4) { 0 },
val scores: List<Int>,
val log: List<String>,
)
@@ -62,6 +62,7 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
leader = (dealer + 1) % 4,
lastTrickWinner = null,
handPoints = listOf(0, 0),
tricksWon = List(4) { 0 },
scores = scores,
log = log,
)
@@ -224,10 +225,14 @@ object KaiserRules : GameRules<KaiserState, KaiserMove> {
val handPoints = played.handPoints.toMutableList()
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
val tricksWon = played.tricksWon.toMutableList()
tricksWon[winner] = tricksWon[winner] + 1
val afterTrick = played.copy(
trick = emptyList(),
completedTrick = trick,
handPoints = handPoints,
tricksWon = tricksWon,
leader = winner,
turn = winner,
lastTrickWinner = winner,
@@ -3,6 +3,7 @@ package org.prolibertate.games.ui.game
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
@@ -16,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
@@ -119,6 +121,16 @@ const val CARD_SWEEP_MILLIS = 520
*/
private const val SWEEP_SPREAD = 0.3f
/**
* The last stretch of the sweep, over which a gathered card fades out. It has
* to be late: the cards vanish exactly as the winner's pile grows by one, and
* that swap is only invisible if they are still whole most of the way there.
*/
private const val SWEEP_FADE = 0.42f
/** How small four cards squared into a bundle end up against one card face. */
private const val GATHERED_SCALE = 0.62f
// ---------------------------------------------------------------------------
// The pure part: where a card lands, and how far through leaving it is.
// ---------------------------------------------------------------------------
@@ -260,6 +272,16 @@ fun sweepProgress(sweep: Float, rest: CardRest, spread: Float = SWEEP_SPREAD): F
fun blend(from: Float, to: Float, fraction: Float): Float =
from + (to - from) * fraction
/**
* How solid a card being gathered up still is. Whole for most of the journey
* and gone at the end of it, so it disappears at the moment the winner's pile
* grows rather than dissolving on the way over.
*/
fun gatherAlpha(leaving: Float): Float = ((1f - leaving) / SWEEP_FADE).coerceIn(0f, 1f)
/** And how far it has been squared down into the bundle it is joining. */
fun gatherScale(leaving: Float): Float = blend(1f, GATHERED_SCALE, leaving.coerceIn(0f, 1f))
// ---------------------------------------------------------------------------
// The Compose part.
// ---------------------------------------------------------------------------
@@ -370,6 +392,192 @@ fun Modifier.landed(landing: CardLanding): Modifier = this
/** How many cards of a pile are worth drawing. Below this it is not a heap. */
private const val PILE_DEPTH = 5
/**
* How many taken tricks are worth drawing separately. Past this the pile stops
* growing rather than climbing off the table — Wizard's last round is twenty
* tricks, and twenty bundles of stagger is not a pile, it is a staircase.
*/
private const val WON_STACK_DEPTH = 8
/** How far each taken trick sits off the one under it, in card widths. */
private const val WON_STACK_STEP = 0.045f
/**
* The tricks a player has taken, squared up and turned face down in front of
* them.
*
* One card back per trick rather than four, which is what a real table looks
* like: a trick is gathered, squared and put down as a single bundle, and from
* above you see the top card of each bundle and the edges of the rest. The
* count is the pile — it is the thing a player actually glances at to see how
* the hand is going, and it is why cards are kept rather than binned.
*/
@Composable
fun WonTrickStack(
tricks: Int,
width: Dp,
modifier: Modifier = Modifier,
facingDegrees: Float = 0f,
/** Where on the table these were gathered from, in pixels. */
fromX: Float = 0f,
fromY: Float = 0f,
) {
if (tricks <= 0) return
val shown = tricks.coerceAtMost(WON_STACK_DEPTH)
val step = width * WON_STACK_STEP
val widthPx = with(LocalDensity.current) { width.toPx() }
val throwMillis = motionMillis(CARD_THROW_MILLIS)
Box(
modifier = modifier
.padding(end = step * shown, bottom = step * shown)
.width(width)
.height(width / CARD_ASPECT),
) {
for (bundle in 0 until shown) {
key(bundle) {
// Each bundle is squared up, not thrown down, so it gets a
// fraction of the scatter a played card gets.
val rest = cardRest(bundle * 613, tilt = REST_TILT_DEGREES * 0.5f)
val top = bundle == shown - 1
val landing = if (!top) {
CardLanding(
offsetX = 0f,
offsetY = 0f,
rotation = facingDegrees + rest.tiltDegrees,
scale = 1f,
elevation = REST_ELEVATION_DP.dp,
)
} else {
// Only the newest bundle arrives; the ones under it have
// been sitting there since earlier in the hand.
rememberCardLanding(
key = tricks,
rest = rest.copy(driftX = 0f, driftY = 0f),
facingDegrees = facingDegrees,
fromX = fromX,
fromY = fromY,
cardWidthPx = widthPx,
durationMillis = throwMillis,
)
}
CardBackView(
width = width,
elevation = landing.elevation,
modifier = Modifier
.offset(x = step * bundle, y = step * bundle)
.landed(landing),
)
}
}
}
}
/** How wide a taken-trick pile is drawn against a card on the table. */
private const val WON_STACK_SCALE = 0.5f
/**
* Everyone's taken tricks, at a table of four with partners opposite.
*
* Each pile sits beside its own player's card, one place clockwise along their
* edge, which is both where a real player would put them and the one spot on
* each edge that nothing else on the table is using.
*/
@Composable
fun BoxScope.TakenTricks(tricksWon: List<Int>, localSeat: Int, cardWidth: Dp) {
val stackWidth = cardWidth * WON_STACK_SCALE
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
for (seat in 0 until 4) {
val relative = ((seat - localSeat) % 4 + 4) % 4
val alignment = when (relative) {
0 -> Alignment.BottomCenter
1 -> Alignment.CenterStart
2 -> Alignment.TopCenter
else -> Alignment.CenterEnd
}
val (nudgeX, nudgeY) = when (relative) {
0 -> cardWidth * 1.6f to 0.dp
1 -> 0.dp to cardWidth * 1.4f
2 -> -(cardWidth * 1.6f) to 0.dp
else -> 0.dp to -(cardWidth * 1.4f)
}
// Gathered in from the middle of the table, which is the opposite way
// from the edge this player sits at.
val (outX, outY) = seatOrigin(relative, 2.5f)
WonTrickStack(
tricks = tricksWon.getOrElse(seat) { 0 },
width = stackWidth,
facingDegrees = relative * 90f,
fromX = -outX * stackWidthPx,
fromY = -outY * stackWidthPx,
modifier = Modifier.align(alignment).offset(x = nudgeX, y = nudgeY),
)
}
}
/**
* The same, round a table of any size: each pile sits half a seat clockwise
* from its player's card, just inside the ring the cards are played on.
*/
@Composable
fun BoxScope.TakenTricksAround(
tricksWon: List<Int>,
localSeat: Int,
players: Int,
cardWidth: Dp,
radiusPx: Float,
) {
val stackWidth = cardWidth * WON_STACK_SCALE
val stackWidthPx = with(LocalDensity.current) { stackWidth.toPx() }
for (seat in 0 until players) {
val relative = ((seat - localSeat) % players + players) % players
val angle = 2.0 * PI * relative / players + PI / players
val x = -sin(angle) * radiusPx
val y = cos(angle) * radiusPx
WonTrickStack(
tricks = tricksWon.getOrElse(seat) { 0 },
width = stackWidth,
facingDegrees = relative * 360f / players,
fromX = (sin(angle) * stackWidthPx * 2.5f).toFloat(),
fromY = (-cos(angle) * stackWidthPx * 2.5f).toFloat(),
modifier = Modifier
.align(Alignment.Center)
.offset { IntOffset(x.roundToInt(), y.roundToInt()) },
)
}
}
/**
* Trick counts, held back until the cards have actually got there.
*
* The rules count a trick the instant its last card is played, which is a beat
* before the cards are gathered and two before they arrive. Reading the count
* straight from the state would grow the winner's pile while the trick was
* still lying in the middle of the table, and the cards would then slide over
* and land on a pile that had already counted them.
*/
@Composable
fun rememberArrivedTricks(
tricksWon: List<Int>,
sweeping: Boolean,
sweepStarted: Boolean,
sweepMillis: Int,
): List<Int> {
var arrived by remember { mutableStateOf(tricksWon) }
LaunchedEffect(tricksWon, sweeping, sweepStarted) {
// Still on the table waiting to be read: the pile has not won it yet.
if (sweeping && !sweepStarted) return@LaunchedEffect
if (sweeping) delay(sweepMillis.toLong())
arrived = tricksWon
}
return arrived
}
/** Holder for [rememberPileWatermark]. Deliberately not snapshot state. */
private class PileWatermark(var value: Int)
@@ -227,8 +227,21 @@ private fun TrickArea(
val travelPx = cardWidthPx * 2.5f
val throwMillis = motionMillis(CARD_THROW_MILLIS)
// The count follows the cards rather than the rules, so a pile grows as the
// trick reaches it and not the moment the last card was played.
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// What each player has taken, face down in front of them. Drawn first
// so a trick still being gathered passes over the pile it is joining.
TakenTricks(arrived, localSeat, cardWidth)
// The turn card sits in the middle of the table during bidding.
state.upCard?.let { up ->
Column(
@@ -300,9 +313,12 @@ private fun TrickArea(
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
// Squared down into the bundle it is joining, and
// still whole until it gets there.
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -240,6 +240,16 @@ private fun ScoreRow(state: HeartsState, localSeat: Int) {
style = MaterialTheme.typography.labelSmall,
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
)
// The tricks this player has taken, face down in front of them.
// Hearts keeps the cards themselves rather than a count, since
// it is the cards that are scored at the end of the round, so
// the number of tricks is however many players-worth of them
// there are.
WonTrickStack(
tricks = state.taken.getOrElse(seat) { emptyList() }.size /
state.options.playerCount,
width = 26.dp,
)
Text("$score", fontWeight = FontWeight.Bold)
// What this round has cost so far, which is the number anybody
// actually watches while a hand is being played.
@@ -218,7 +218,21 @@ private fun TrickArea(
val travelPx = cardWidthPx * 2.5f
val throwMillis = motionMillis(CARD_THROW_MILLIS)
// Kaiser scores in points rather than tricks, but the cards are still won
// and still kept, and how many each player is sitting on is worth seeing.
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// Drawn first, so a trick still being gathered passes over the pile it
// is on its way to joining.
TakenTricks(arrived, localSeat, cardWidth)
if (cardsOnTable.isEmpty()) {
Text(
text = when {
@@ -270,9 +284,10 @@ private fun TrickArea(
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -230,6 +230,23 @@ private fun TrickArea(
val travelPx = cardWidthPx * 2f
val throwMillis = motionMillis(CARD_THROW_MILLIS)
val arrived = rememberArrivedTricks(
tricksWon = state.tricksWon,
sweeping = sweeping,
sweepStarted = sweepStarted,
sweepMillis = motionMillis(CARD_SWEEP_MILLIS),
)
// What each player has taken, face down beside their own place at the
// ring. Drawn first so a trick being gathered passes over its pile.
TakenTricksAround(
tricksWon = arrived,
localSeat = localSeat,
players = players,
cardWidth = cardWidth,
radiusPx = radiusPx,
)
if (cardsOnTable.isEmpty()) {
Text(
text = when {
@@ -279,9 +296,10 @@ private fun TrickArea(
)
}
.graphicsLayer {
scaleX = landing.scale
scaleY = landing.scale
alpha = landing.alpha * (1f - leaving)
val gathered = landing.scale * gatherScale(leaving)
scaleX = gathered
scaleY = gathered
alpha = landing.alpha * gatherAlpha(leaving)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -250,6 +250,39 @@ class KaiserRulesTest {
assertEquals("all four held for display", 4, state.completedTrick.size)
}
/**
* Kaiser scores in points, not tricks, so nothing in the rules reads this
* count — the table does, to draw the cards each player has taken. That is
* exactly why it is worth a test: a field no rule depends on is a field
* that can quietly stop being maintained.
*/
@Test
fun `tricks are counted to the seat that took them`() {
// Everything is clubs and nothing is trump, so each trick simply goes
// to the highest card in it: the ace, then the king.
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.SEVEN, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.JACK, Suit.CLUBS)),
listOf(Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
assertEquals("nothing taken at the deal", listOf(0, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the ace takes the first", listOf(1, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the king takes the second", listOf(1, 0, 0, 1), state.tricksWon)
assertEquals("and they add up", 2, state.tricksWon.sum())
}
// -- Scoring ------------------------------------------------------------
@Test
@@ -209,6 +209,34 @@ class CardPhysicsTest {
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
}
/**
* A gathered card has to still be whole when it reaches the winner's pile,
* because it vanishes at the same moment the pile grows by one and that
* swap is only invisible if there was something there to swap.
*/
@Test
fun `a gathered card is whole most of the way and gone at the end`() {
assertEquals(1f, gatherAlpha(0f), 0f)
assertEquals(1f, gatherAlpha(0.5f), 0f)
assertEquals(0f, gatherAlpha(1f), 0f)
var previous = 1f
for (step in 0..100) {
val now = gatherAlpha(step / 100f)
assertTrue("went back up at $step", now <= previous + 0.0001f)
assertTrue(now in 0f..1f)
previous = now
}
}
/** And is squared down into the bundle rather than arriving full size. */
@Test
fun `a gathered card is squared down on the way`() {
assertEquals(1f, gatherScale(0f), 0f)
assertTrue(gatherScale(1f) < 1f)
assertTrue(gatherScale(1f) > 0.5f)
assertTrue(gatherScale(0.5f) < gatherScale(0.25f))
}
/** A trick that never completes must not leave a card part way off the table. */
@Test
fun `a sweep that never starts moves nothing`() {