From df4d90f40b12d9b5167d2c4edcc6913d112b2e63 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 26 Jul 2026 21:46:28 -0400 Subject: [PATCH] Polish table and clarify showdown results --- CLAUDE.md | 8 + .../com/jsjdesigns/poker/ActionButtons.kt | 37 + .../java/com/jsjdesigns/poker/HandSummary.kt | 52 + .../java/com/jsjdesigns/poker/TableScreen.kt | 1121 +++++++++++++---- .../com/jsjdesigns/poker/ActionButtonsTest.kt | 41 + .../com/jsjdesigns/poker/HandSummaryTest.kt | 86 +- designs/Poker Table Polish.dc.html | 455 +++++++ .../kotlin/com/jsjdesigns/poker/game/Table.kt | 79 +- .../jsjdesigns/poker/game/TableRulesTest.kt | 23 + 9 files changed, 1625 insertions(+), 277 deletions(-) create mode 100644 designs/Poker Table Polish.dc.html diff --git a/CLAUDE.md b/CLAUDE.md index 91fcf2e..ac1cd3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" | `engine/src/commonMain/.../game/` | `Table` — betting rounds, side pots, showdown | | `sim/` | JVM-only headless simulator used to **tune** bot profiles | | `app/` | Android app: Compose table, `PokerViewModel` | +| `designs/` | Visual direction and interaction references; current table follows 1A | | `assets/cards/` | 52 CC0 card faces + generated backs (**source of truth**) | | `tools/generate_card_assets.sh` | Rasterises those SVGs into `app/.../drawable-*` | @@ -139,9 +140,16 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" Persona output is accepted only beside the exact hand, street, and action number that requested it; a late response is dropped rather than shown against a newer decision. +- A completed hand cannot be reduced to one flat winner list. `HandResult` + retains each `PotAward` in main/side-pot order and only the hands publicly + revealed at showdown. The result UI reads those authoritative awards, so two + players winning different pots are never presented as though they tied. - Android is playable in Compose. Engine snapshots are paced through a rendezvous channel, human decisions are matched by engine-owned tokens, and a completed hand stays on screen until the player explicitly starts the next one. + The portrait table follows the 1A design direction: oval felt, orbiting seats, + spatial bets, large 2:3 card art, always-visible legal sizing controls, and a + winner-focused pot/hand breakdown at completion. - A cash-game session begins at an explicit take-a-seat screen. Opponents may auto-reload below the big blind; the human is never silently topped up and must explicitly choose "Reload to N & deal" from the completed-hand screen. diff --git a/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt b/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt index d42b977..13fe439 100644 --- a/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt +++ b/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt @@ -22,6 +22,11 @@ data class ActionButtons( val fixedRaiseTo: Int, ) +data class RaisePreset( + val label: String, + val amount: Int, +) + fun buttonsFor(offer: DecisionOffer): ActionButtons { // When a stack cannot cover a full min-raise it may still shove; the engine // clamps such a raise to maxRaiseTo. Requiring maxRaiseTo > minRaiseTo to show @@ -55,3 +60,35 @@ fun buttonsFor(offer: DecisionOffer): ActionButtons { /** Label for the raise button at [amount]. */ fun raiseLabel(buttons: ActionButtons, amount: Int): String = if (amount >= buttons.sliderMax) "All in" else "Raise $amount" + +/** + * Honest, legal raise-to shortcuts for the always-visible sizing rail. + * + * Facing a bet, a fraction describes the extra raise after calling: the pot + * after a call is [DecisionOffer.pot] plus the affordable call, and the final + * round commitment is the current bet plus that fraction. Every result is + * clamped to the exact engine-provided range; duplicates disappear rather than + * showing several labels that submit the same action. + */ +fun raisePresets( + offer: DecisionOffer, + buttons: ActionButtons = buttonsFor(offer), +): List { + if (!buttons.showRaise) return emptyList() + val low = buttons.sliderMin + val high = buttons.sliderMax + if (low >= high) return listOf(RaisePreset("All in", high)) + + val potAfterCall = offer.pot + offer.callAmount + val halfPot = (offer.currentBet + potAfterCall / 2).coerceIn(low, high) + val fullPot = (offer.currentBet + potAfterCall).coerceIn(low, high) + + return buildList { + add(RaisePreset("Min", low)) + if (halfPot in (low + 1) until high) add(RaisePreset("½ pot", halfPot)) + if (fullPot in (low + 1) until high && fullPot != halfPot) { + add(RaisePreset("Pot", fullPot)) + } + add(RaisePreset("All in", high)) + } +} diff --git a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt index 47aaf95..24dc3b6 100644 --- a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt +++ b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt @@ -1,7 +1,22 @@ package com.jsjdesigns.poker +import com.jsjdesigns.poker.core.HandEvaluator import com.jsjdesigns.poker.game.HandResult +data class AwardWinner( + val seat: Int, + val label: String, + val amountWon: Int, + val hole: List, + val handDescription: String?, +) + +data class PotAwardSummary( + val label: String, + val amount: Int, + val winners: List, +) + /** * Immutable, presentation-ready outcome for one completed hand. * @@ -18,6 +33,8 @@ data class HandSummary( val potSize: Int, val heroNet: Int, val wentToShowdown: Boolean, + /** Main/side-pot breakdown with public winning cards and evaluated hand names. */ + val awards: List, ) { val title: String get() = when { @@ -47,6 +64,40 @@ fun handSummaryFor( require(heroSeat in result.net.indices) { "hero seat is outside the result" } require(result.winners.isNotEmpty()) { "a completed hand must have a winner" } require(result.winners.all { it in seatNames.indices }) { "winner seat is outside the roster" } + require(result.potAwards.isNotEmpty()) { "a completed hand must award at least one pot" } + require(result.potAwards.sumOf { it.amount } == result.potSize) { + "pot awards must account for the contested pot" + } + val showdownBySeat = result.showdownHands.associateBy { it.seat } + if (result.wentToShowdown) { + require(result.potAwards.flatMap { it.payouts }.all { it.seat in showdownBySeat }) { + "every showdown winner must include its public hand" + } + } + val multiplePots = result.potAwards.size > 1 + val awards = result.potAwards.mapIndexed { index, award -> + require(award.payouts.all { it.seat in seatNames.indices }) { + "pot winner seat is outside the roster" + } + PotAwardSummary( + label = when { + !multiplePots -> "Pot" + index == 0 -> "Main pot" + else -> "Side pot $index" + }, + amount = award.amount, + winners = award.payouts.map { payout -> + val showdown = showdownBySeat[payout.seat] + AwardWinner( + seat = payout.seat, + label = if (payout.seat == heroSeat) "You" else seatNames[payout.seat], + amountWon = payout.amount, + hole = showdown?.hole.orEmpty(), + handDescription = showdown?.let { HandEvaluator.describe(it.score) }, + ) + }, + ) + } return HandSummary( handNumber = handNumber, @@ -57,5 +108,6 @@ fun handSummaryFor( potSize = result.potSize, heroNet = result.net[heroSeat], wentToShowdown = result.wentToShowdown, + awards = awards, ) } diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index f523abb..df4c83c 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -1,18 +1,26 @@ package com.jsjdesigns.poker +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -20,6 +28,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -31,24 +40,44 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.jsjdesigns.poker.core.Card +import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.SeatSnapshot import com.jsjdesigns.poker.game.TableSnapshot -private val Felt = Color(0xFF0B3D26) +private val Night = Color(0xFF04100C) +private val TableBackground = Color(0xFF071D16) +private val FeltLight = Color(0xFF1E6E51) +private val FeltMid = Color(0xFF155440) +private val FeltDark = Color(0xFF0C3A2C) +private val RailLight = Color(0xFF3B2A17) +private val RailDark = Color(0xFF211407) +private val TableGold = Color(0xFFE3C27E) +private val GoldLight = Color(0xFFF0D9A5) +private val Cream = Color(0xFFF3EFE6) +private val Muted = Color(0xFF9FB0A6) +private val Danger = Color(0xFF8C242A) +private val Panel = Color(0xE604120D) @Composable fun TableScreen(vm: PokerViewModel) { val state by vm.state.collectAsStateWithLifecycle() val rawOffer by vm.offer.collectAsStateWithLifecycle() val snap = state.snapshot - // Only show an action bar that belongs to the table currently on screen. val offer = state.liveOffer(rawOffer) val handSummary = state.visibleHandSummary() val coachReview = state.visibleCoachReview() @@ -58,108 +87,27 @@ fun TableScreen(vm: PokerViewModel) { Column( modifier = Modifier .fillMaxSize() - .background(Felt) - .systemBarsPadding() - .padding(12.dp), + .background(Night) + .systemBarsPadding(), ) { - Text( - // snapshot.handNumber, not handsPlayed + 1: the counter increments when - // the hand *finishes*, so during the showdown hold it would label the - // result of hand 1 as "Hand 2". - text = "Hand ${snap?.handNumber ?: 1} ${snap?.street ?: ""}", - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, + TableHeader(snap, state.gameConfig) + PokerTable( + snapshot = snap, + status = status, + tableTalk = tableTalk, + handSummary = handSummary, + modifier = Modifier + .fillMaxWidth() + .weight(1f), ) - - Spacer(Modifier.height(8.dp)) - - // Opponents - // Weighted rather than fixed-width: five opponents must all fit on a - // phone, and a horizontally scrolling table hides players from you. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - snap?.seats?.filter { it.index != HERO_SEAT }?.forEach { seat -> - Box(Modifier.weight(1f)) { - OpponentSeat(seat, isTurn = snap.toAct == seat.index) - } - } - } - - Spacer(Modifier.weight(1f)) - - // Board and pot - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "POT ${snap?.pot ?: 0}", - color = Color(0xFFE3C179), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - Text( - text = status?.text.orEmpty(), - color = when (status?.tone) { - TableStatusTone.ACCENT -> Color(0xFFE3C179) - TableStatusTone.NORMAL -> Color.White.copy(alpha = 0.78f) - TableStatusTone.MUTED, null -> Color.White.copy(alpha = 0.48f) - }, - fontWeight = if (status?.tone == TableStatusTone.ACCENT) { - FontWeight.Bold - } else { - FontWeight.Normal - }, - fontSize = 13.sp, - modifier = Modifier.height(24.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = tableTalk?.let { "${it.speakerName}: “${it.text}”" }.orEmpty(), - color = Color.White.copy(alpha = 0.68f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.height(22.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - val board = snap?.board.orEmpty() - repeat(5) { i -> - if (i < board.size) { - CardImage(board[i], Modifier.size(54.dp, 81.dp)) - } else { - Box( - Modifier - .size(54.dp, 81.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color.White.copy(alpha = 0.06f)), - ) - } - } - } - } - - Spacer(Modifier.weight(1f)) - - // Hero - val hero = snap?.seats?.firstOrNull { it.index == HERO_SEAT } - HeroSeat(hero, isTurn = snap?.toAct == HERO_SEAT) - - Spacer(Modifier.height(12.dp)) - - ActionBar( + TableControls( offer = offer, handSummary = handSummary, nextHandRequested = state.nextHandRequested, rebuyRequired = state.heroNeedsRebuy, buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN, coachReview = coachReview, - heroFolded = hero?.folded == true, + heroFolded = snap?.seats?.firstOrNull { it.index == HERO_SEAT }?.folded == true, onFold = vm::fold, onCheckCall = vm::checkOrCall, onRaise = vm::raiseTo, @@ -169,45 +117,50 @@ fun TableScreen(vm: PokerViewModel) { } @Composable -private fun OpponentSeat(seat: SeatSnapshot, isTurn: Boolean) { - Card( - colors = CardDefaults.cardColors( - containerColor = if (isTurn) Color(0xFF1D6B45) else Color.White.copy(alpha = 0.07f), - ), - modifier = Modifier.fillMaxWidth(), +private fun TableHeader( + snapshot: TableSnapshot?, + config: CashGameConfig?, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Column( - modifier = Modifier.padding(6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { - if (seat.folded) { - Box(Modifier.size(24.dp, 36.dp)) - } else { - val hole = seat.hole - repeat(2) { i -> - CardImage(hole?.getOrNull(i), Modifier.size(24.dp, 36.dp)) - } - } - } - Spacer(Modifier.height(4.dp)) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { Text( - seat.name + if (seat.isButton) " ⏺" else "", - color = Color.White, - fontSize = 11.sp, + "HAND ${snapshot?.handNumber ?: 1}", + color = Cream, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + ) + Text( + snapshot?.street?.name.orEmpty(), + color = Muted.copy(alpha = 0.8f), fontWeight = FontWeight.Medium, - modifier = Modifier.alpha(if (seat.folded) 0.4f else 1f), + fontSize = 10.sp, + letterSpacing = 1.4.sp, ) - Text( - if (seat.allIn) "ALL IN" else "${seat.stack}", - color = if (seat.allIn) Color(0xFFE3C179) else Color.White.copy(alpha = 0.7f), - fontSize = 11.sp, - ) - if (seat.committedThisRound > 0) { + } + Surface( + shape = RoundedCornerShape(50), + color = Color.Black.copy(alpha = 0.24f), + border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.24f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ChipDot(TableGold, 11.dp) Text( - "bet ${seat.committedThisRound}", - color = Color(0xFFE3C179), - fontSize = 10.sp, + "${config?.smallBlind ?: DEFAULT_SMALL_BLIND} / " + + "${config?.bigBlind ?: DEFAULT_BIG_BLIND}", + color = TableGold, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, ) } } @@ -215,46 +168,488 @@ private fun OpponentSeat(seat: SeatSnapshot, isTurn: Boolean) { } @Composable -private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) { - val folded = seat?.folded == true - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), +private fun PokerTable( + snapshot: TableSnapshot?, + status: TableStatus?, + tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?, + handSummary: HandSummary?, + modifier: Modifier = Modifier, +) { + val seats = snapshot?.seats.orEmpty() + val winners = handSummary?.awards + .orEmpty() + .flatMap { award -> award.winners.map(AwardWinner::seat) } + .toSet() + + BoxWithConstraints( + modifier = modifier + .padding(horizontal = 6.dp) + .clip(RoundedCornerShape(26.dp)) + .background(TableBackground), ) { - // A processed fold has to *look* folded, or a correct fold reads as a bug. - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.alpha(if (folded) 0.25f else 1f), - ) { - repeat(2) { i -> - CardImage(seat?.hole?.getOrNull(i), Modifier.size(64.dp, 96.dp)) + val compact = maxHeight < 520.dp + TableFelt(Modifier.fillMaxSize()) + + val opponentModifiers = listOf( + Modifier.align(Alignment.CenterStart).offset(x = 7.dp, y = (-26).dp), + Modifier.align(Alignment.TopStart).offset(x = 55.dp, y = 18.dp), + Modifier.align(Alignment.TopCenter).offset(y = 2.dp), + Modifier.align(Alignment.TopEnd).offset(x = (-55).dp, y = 18.dp), + Modifier.align(Alignment.CenterEnd).offset(x = (-7).dp, y = (-26).dp), + ) + seats.filter { it.index != HERO_SEAT } + .forEachIndexed { index, seat -> + OpponentSeat( + seat = seat, + isTurn = snapshot?.toAct == seat.index, + isWinner = seat.index in winners, + compact = compact, + modifier = opponentModifiers.getOrElse(index) { + Modifier.align(Alignment.TopCenter) + }, + ) + } + + BoardAndStatus( + snapshot = snapshot, + status = status, + tableTalk = tableTalk, + compact = compact, + modifier = Modifier + .align(Alignment.Center) + .offset(y = if (compact) 12.dp else 20.dp), + ) + + HeroSeat( + seat = seats.firstOrNull { it.index == HERO_SEAT }, + isTurn = snapshot?.toAct == HERO_SEAT, + isWinner = HERO_SEAT in winners, + compact = compact, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 10.dp), + ) + } +} + +@Composable +private fun TableFelt(modifier: Modifier = Modifier) { + Canvas(modifier) { + val railTop = 2.dp.toPx() + val railBottom = 18.dp.toPx() + val horizontalBleed = 22.dp.toPx() + val railSize = Size( + width = size.width + horizontalBleed * 2, + height = size.height - railTop - railBottom, + ) + val railOffset = Offset(-horizontalBleed, railTop) + drawOval( + brush = Brush.linearGradient( + colors = listOf(RailLight, RailDark), + start = railOffset, + end = Offset(size.width, size.height), + ), + topLeft = railOffset, + size = railSize, + ) + + val inset = 9.dp.toPx() + val feltOffset = Offset(railOffset.x + inset, railOffset.y + inset) + val feltSize = Size(railSize.width - inset * 2, railSize.height - inset * 2) + drawOval( + brush = Brush.radialGradient( + colors = listOf(FeltLight, FeltMid, FeltDark), + center = Offset(size.width * 0.5f, size.height * 0.38f), + radius = size.maxDimension * 0.67f, + ), + topLeft = feltOffset, + size = feltSize, + ) + drawOval( + color = TableGold.copy(alpha = 0.34f), + topLeft = feltOffset, + size = feltSize, + style = Stroke(width = 1.dp.toPx()), + ) + } +} + +@Composable +private fun OpponentSeat( + seat: SeatSnapshot, + isTurn: Boolean, + isWinner: Boolean, + compact: Boolean, + modifier: Modifier = Modifier, +) { + val faded = seat.folded && !isWinner + val faceWidth = if (compact) 29.dp else 33.dp + val faceHeight = faceWidth * 1.5f + Column( + modifier = modifier + .width(if (compact) 68.dp else 76.dp) + .alpha(if (faded) 0.38f else 1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Box(contentAlignment = Alignment.Center) { + val highlight = when { + isWinner -> TableGold + isTurn -> GoldLight + else -> Color.White.copy(alpha = 0.12f) + } + Box( + modifier = Modifier + .border( + width = if (isWinner || isTurn) 2.dp else 1.dp, + color = highlight, + shape = RoundedCornerShape(12.dp), + ) + .padding(3.dp), + contentAlignment = Alignment.Center, + ) { + val hole = seat.hole + if (seat.revealed && hole != null) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(2) { index -> + CardImage( + hole.getOrNull(index), + Modifier.size(faceWidth, faceHeight), + ) + } + } + } else { + OpponentAvatar(seat) + } + } + if (seat.isButton) { + DealerButton(Modifier.align(Alignment.BottomStart).offset(x = (-5).dp, y = 4.dp)) } } - Column { - Text( - (seat?.name ?: "You") + if (seat?.isButton == true) " ⏺" else "", - color = when { - folded -> Color.White.copy(alpha = 0.4f) - isTurn -> Color(0xFFE3C179) - else -> Color.White - }, - fontWeight = FontWeight.Bold, - ) - if (folded) { - Text("FOLDED", color = Color(0xFFC9545B), fontSize = 12.sp, fontWeight = FontWeight.Bold) + + Surface( + shape = RoundedCornerShape(8.dp), + color = Panel, + border = androidx.compose.foundation.BorderStroke( + 1.dp, + if (isWinner) TableGold.copy(alpha = 0.65f) else Color.White.copy(alpha = 0.06f), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 3.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + seat.name, + color = Cream, + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + when { + seat.folded -> "folded" + seat.allIn -> "ALL IN" + else -> "${seat.stack}" + }, + color = when { + seat.folded -> Muted.copy(alpha = 0.65f) + seat.allIn || isWinner -> TableGold + else -> TableGold.copy(alpha = 0.9f) + }, + fontWeight = if (seat.allIn || isWinner) FontWeight.Bold else FontWeight.Medium, + fontSize = 10.sp, + ) } - Text("${seat?.stack ?: 0}", color = Color.White.copy(alpha = 0.75f)) - if ((seat?.committedThisRound ?: 0) > 0) { - Text("bet ${seat?.committedThisRound}", color = Color(0xFFE3C179), fontSize = 12.sp) + } + + if (seat.committedThisRound > 0 && !seat.folded) { + BetMarker(seat.committedThisRound) + } else { + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun OpponentAvatar(seat: SeatSnapshot) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + listOf(Color(0xFF3A5C4B), Color(0xFF183226)), + ), + ), + contentAlignment = Alignment.Center, + ) { + Text( + seat.name.take(1).uppercase(), + color = GoldLight, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) + if (!seat.folded) { + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 5.dp, y = 2.dp), + horizontalArrangement = Arrangement.spacedBy((-6).dp), + ) { + repeat(2) { + CardImage(null, Modifier.size(14.dp, 21.dp)) + } } } } } @Composable -private fun ActionBar( - offer: com.jsjdesigns.poker.game.DecisionOffer?, +private fun HeroSeat( + seat: SeatSnapshot?, + isTurn: Boolean, + isWinner: Boolean, + compact: Boolean, + modifier: Modifier = Modifier, +) { + val folded = seat?.folded == true + val cardWidth = if (compact) 58.dp else 68.dp + val cardHeight = cardWidth * 1.5f + Surface( + modifier = modifier.alpha(if (folded) 0.42f else 1f), + shape = RoundedCornerShape(16.dp), + color = Panel, + border = androidx.compose.foundation.BorderStroke( + if (isTurn || isWinner) 2.dp else 1.dp, + when { + isWinner -> TableGold + isTurn -> GoldLight + else -> Color.White.copy(alpha = 0.09f) + }, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(2) { index -> + CardImage( + seat?.hole?.getOrNull(index), + Modifier.size(cardWidth, cardHeight), + ) + } + } + Column( + modifier = Modifier.padding(bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + seat?.name ?: "You", + color = Cream, + fontWeight = FontWeight.ExtraBold, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${seat?.stack ?: 0}", + color = TableGold, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + when { + folded -> StatusTag("FOLDED", Danger) + seat?.allIn == true -> StatusTag("ALL IN", TableGold) + isWinner -> StatusTag("WINNER", TableGold) + } + if ((seat?.committedThisRound ?: 0) > 0) { + BetMarker(seat?.committedThisRound ?: 0) + } + } + } + } +} + +@Composable +private fun BoardAndStatus( + snapshot: TableSnapshot?, + status: TableStatus?, + tableTalk: com.jsjdesigns.poker.bot.TableTalkLine?, + compact: Boolean, + modifier: Modifier = Modifier, +) { + val board = snapshot?.board.orEmpty() + val cardWidth = if (compact) 45.dp else 52.dp + val cardHeight = cardWidth * 1.5f + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), + ) { + PotPill(snapshot?.pot ?: 0) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(5) { index -> + if (index < board.size) { + CardImage(board[index], Modifier.size(cardWidth, cardHeight)) + } else { + Box( + Modifier + .size(cardWidth, cardHeight) + .clip(RoundedCornerShape(6.dp)) + .border( + 1.dp, + TableGold.copy(alpha = 0.18f), + RoundedCornerShape(6.dp), + ) + .background(Color.Black.copy(alpha = 0.08f)), + ) + } + } + } + status?.let { + StatusPill( + text = it.text, + accent = it.tone == TableStatusTone.ACCENT, + ) + } + tableTalk?.let { + Text( + "${it.speakerName}: “${it.text}”", + color = Cream.copy(alpha = 0.74f), + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(260.dp), + ) + } + } +} + +@Composable +private fun PotPill(pot: Int) { + Surface( + shape = RoundedCornerShape(50), + color = Color(0x9904140E), + border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.32f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 15.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "POT", + color = TableGold.copy(alpha = 0.72f), + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 1.8.sp, + ) + Text( + "$pot", + color = GoldLight, + fontWeight = FontWeight.ExtraBold, + fontSize = 17.sp, + ) + } + } +} + +@Composable +private fun StatusPill(text: String, accent: Boolean) { + Surface( + shape = RoundedCornerShape(50), + color = if (accent) TableGold.copy(alpha = 0.17f) else Color.Black.copy(alpha = 0.24f), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + if (accent) TableGold.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.08f), + ), + ) { + Text( + text, + color = if (accent) GoldLight else Cream.copy(alpha = 0.8f), + fontWeight = if (accent) FontWeight.Bold else FontWeight.Medium, + fontSize = 10.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(250.dp) + .padding(horizontal = 11.dp, vertical = 5.dp), + ) + } +} + +@Composable +private fun BetMarker(amount: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + ChipDot(Color(0xFFE9E7DD), 14.dp) + Text( + "$amount", + color = Cream.copy(alpha = 0.9f), + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + ) + } +} + +@Composable +private fun DealerButton(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(18.dp) + .clip(CircleShape) + .background(Color(0xFFF3EBD8)) + .border(1.dp, Color(0xFFA79463), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text("D", color = Color(0xFF5A4A22), fontWeight = FontWeight.ExtraBold, fontSize = 8.sp) + } +} + +@Composable +private fun StatusTag(text: String, color: Color) { + Surface( + shape = RoundedCornerShape(6.dp), + color = color.copy(alpha = 0.16f), + border = androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.5f)), + ) { + Text( + text, + color = color, + fontWeight = FontWeight.ExtraBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp, + modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun ChipDot(color: Color, size: Dp) { + Canvas(Modifier.size(size)) { + drawCircle(color = color) + drawCircle( + color = Color.White.copy(alpha = 0.48f), + radius = this.size.minDimension * 0.34f, + style = Stroke(width = 1.dp.toPx()), + ) + } +} + +@Composable +private fun TableControls( + offer: DecisionOffer?, handSummary: HandSummary?, nextHandRequested: Boolean, rebuyRequired: Boolean, @@ -266,85 +661,343 @@ private fun ActionBar( onRaise: (Long, Int) -> Unit, onNextHand: (Int) -> Unit, ) { - if (offer == null) { - if (handSummary != null) { - HandResultBar( + Surface( + modifier = Modifier.fillMaxWidth(), + color = Night, + ) { + when { + offer != null -> DecisionControls(offer, onFold, onCheckCall, onRaise) + handSummary != null -> HandResultPanel( summary = handSummary, nextHandRequested = nextHandRequested, rebuyRequired = rebuyRequired, buyIn = buyIn, onNextHand = onNextHand, ) - } else if (coachReview != null) { - CoachBar(coachPresentation(coachReview)) - } else { - Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { + coachReview != null -> CoachBar(coachPresentation(coachReview)) + else -> Box( + Modifier + .fillMaxWidth() + .height(80.dp), + contentAlignment = Alignment.Center, + ) { Text( - if (heroFolded) "You folded — sitting out this hand" else "Waiting…", - color = Color.White.copy(alpha = 0.4f), + if (heroFolded) "You folded — watching the hand finish" else "Waiting…", + color = Muted.copy(alpha = 0.55f), + fontSize = 13.sp, ) } } - return } +} +@Composable +private fun DecisionControls( + offer: DecisionOffer, + onFold: (Long) -> Unit, + onCheckCall: (Long) -> Unit, + onRaise: (Long, Int) -> Unit, +) { val buttons = buttonsFor(offer) var raiseTo by remember { mutableIntStateOf(buttons.fixedRaiseTo) } - // Reset whenever a new decision arrives, or it keeps the previous one's value. LaunchedEffect(offer.token) { raiseTo = buttons.fixedRaiseTo } - Column(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + listOf(Color.Transparent, Night.copy(alpha = 0.95f), Night), + ), + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { if (buttons.showRaise && buttons.showSlider) { - Text("Raise to $raiseTo", color = Color.White, fontSize = 13.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "RAISE TO", + color = Muted.copy(alpha = 0.72f), + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + letterSpacing = 1.4.sp, + ) + Text( + "$raiseTo", + color = GoldLight, + fontWeight = FontWeight.ExtraBold, + fontSize = 19.sp, + ) + } Slider( value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(), onValueChange = { raiseTo = it.toInt() }, valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(), colors = SliderDefaults.colors( - thumbColor = Color(0xFFE3C179), - activeTrackColor = Color(0xFFE3C179), - inactiveTrackColor = Color.White.copy(alpha = 0.22f), + thumbColor = GoldLight, + activeTrackColor = TableGold, + inactiveTrackColor = Color.White.copy(alpha = 0.12f), ), + modifier = Modifier.height(26.dp), ) + val presets = raisePresets(offer, buttons) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + presets.forEach { preset -> + Button( + onClick = { raiseTo = preset.amount }, + modifier = Modifier + .weight(1f) + .height(32.dp), + contentPadding = ButtonDefaults.ContentPadding, + colors = ButtonDefaults.buttonColors( + containerColor = if (raiseTo == preset.amount) { + TableGold.copy(alpha = 0.2f) + } else { + Color.White.copy(alpha = 0.06f) + }, + contentColor = if (raiseTo == preset.amount) GoldLight else Muted, + ), + shape = RoundedCornerShape(9.dp), + ) { + Text( + "${preset.label} ${preset.amount}", + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + maxLines = 1, + ) + } + } + } } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (buttons.showFold) { - Button( - onClick = { onFold(offer.token) }, + ActionButton( + title = "Fold", + detail = null, + container = Danger.copy(alpha = 0.58f), + content = Color(0xFFF0BDBD), modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF8F1218), - contentColor = Color.White, - ), - ) { Text("Fold") } + onClick = { onFold(offer.token) }, + ) } - - Button( - onClick = { onCheckCall(offer.token) }, + ActionButton( + title = when { + offer.canCheck -> "Check" + offer.callIsAllIn -> "All in" + else -> "Call" + }, + detail = offer.callAmount.takeUnless { offer.canCheck }?.toString(), + container = Color.White.copy(alpha = 0.09f), + content = Cream, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF2C6E49), - contentColor = Color.White, - ), - ) { Text(buttons.checkOrCallLabel) } - + onClick = { onCheckCall(offer.token) }, + ) if (buttons.showRaise) { val amount = if (buttons.showSlider) { raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax) } else { buttons.fixedRaiseTo } - Button( + ActionButton( + title = if (amount >= buttons.sliderMax) "All in" else "Raise", + detail = if (amount >= buttons.sliderMax) "$amount" else "to $amount", + container = Brush.verticalGradient(listOf(GoldLight, Color(0xFFC79A38))), + content = Color(0xFF22190A), + modifier = Modifier.weight(1.12f), onClick = { onRaise(offer.token, amount) }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF14357A), - contentColor = Color.White, - ), - ) { Text(raiseLabel(buttons, amount)) } + ) + } + } + } +} + +@Composable +private fun ActionButton( + title: String, + detail: String?, + container: Color, + content: Color, + modifier: Modifier, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier.height(54.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content), + ) { + ActionButtonText(title, detail, content) + } +} + +@Composable +private fun ActionButton( + title: String, + detail: String?, + container: Brush, + content: Color, + modifier: Modifier, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier + .height(54.dp) + .background(container, RoundedCornerShape(14.dp)), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = content, + ), + ) { + ActionButtonText(title, detail, content) + } +} + +@Composable +private fun ActionButtonText(title: String, detail: String?, color: Color) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, color = color, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp) + detail?.let { + Text(it, color = color.copy(alpha = 0.68f), fontWeight = FontWeight.Bold, fontSize = 10.sp) + } + } +} + +@Composable +private fun HandResultPanel( + summary: HandSummary, + nextHandRequested: Boolean, + rebuyRequired: Boolean, + buyIn: Int, + onNextHand: (Int) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + summary.title, + color = GoldLight, + fontWeight = FontWeight.ExtraBold, + fontSize = 20.sp, + ) + Text( + summary.detail, + color = Cream.copy(alpha = 0.72f), + fontSize = 11.sp, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + summary.awards.forEach { award -> + PotAwardCard(award) + } + } + if (rebuyRequired) { + Text( + "Your stack cannot cover the big blind.", + color = Color(0xFFC9545B), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } + Button( + onClick = { onNextHand(summary.handNumber) }, + enabled = !nextHandRequested, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(13.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF1C6848), + contentColor = Cream, + disabledContainerColor = Color(0xFF1C6848).copy(alpha = 0.45f), + disabledContentColor = Cream.copy(alpha = 0.65f), + ), + ) { + Text( + continuationButtonLabel(nextHandRequested, rebuyRequired, buyIn), + fontWeight = FontWeight.Bold, + ) + } + } +} + +@Composable +private fun PotAwardCard(award: PotAwardSummary) { + Card( + colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)), + border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.22f)), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // For a split pot, showing only the first winner's cards would imply + // those cards alone explain the award. The board remains visible and + // every tied winner is named instead. + val visibleCards = award.winners + .singleOrNull() + ?.hole + .orEmpty() + if (visibleCards.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + visibleCards.take(2).forEach { card -> + CardImage(card, Modifier.size(34.dp, 51.dp)) + } + } + } + Column(modifier = Modifier.width(112.dp)) { + Text( + "${award.label.uppercase()} ${award.amount}", + color = TableGold, + fontWeight = FontWeight.ExtraBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp, + ) + Text( + if (award.winners.size == 1) { + award.winners.single().label + } else { + award.winners.joinToString(" • ") { "${it.label} ${it.amountWon}" } + }, + color = Cream, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val descriptions = award.winners.mapNotNull(AwardWinner::handDescription).distinct() + if (descriptions.isNotEmpty()) { + Text( + descriptions.joinToString(" / "), + color = GoldLight.copy(alpha = 0.82f), + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -353,19 +1006,21 @@ private fun ActionBar( @Composable private fun CoachBar(presentation: CoachPresentation) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFF14357A).copy(alpha = 0.48f)), ) { Column(Modifier.fillMaxWidth().padding(12.dp)) { Text( "COACH • ${presentation.title}", - color = Color(0xFFE3C179), + color = TableGold, fontWeight = FontWeight.Bold, fontSize = 12.sp, ) Text( presentation.detail, - color = Color.White.copy(alpha = 0.78f), + color = Cream.copy(alpha = 0.78f), fontSize = 12.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -374,64 +1029,14 @@ private fun CoachBar(presentation: CoachPresentation) { } } -@Composable -private fun HandResultBar( - summary: HandSummary, - nextHandRequested: Boolean, - rebuyRequired: Boolean, - buyIn: Int, - onNextHand: (Int) -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.09f)), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - summary.title, - color = Color(0xFFE3C179), - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - Text( - summary.detail, - color = Color.White.copy(alpha = 0.7f), - fontSize = 12.sp, - ) - if (rebuyRequired) { - Text( - "Your stack cannot cover the big blind.", - color = Color(0xFFC9545B), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) - } - Spacer(Modifier.height(8.dp)) - Button( - onClick = { onNextHand(summary.handNumber) }, - enabled = !nextHandRequested, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF2C6E49), - contentColor = Color.White, - disabledContainerColor = Color(0xFF2C6E49).copy(alpha = 0.45f), - disabledContentColor = Color.White.copy(alpha = 0.65f), - ), - ) { - Text(continuationButtonLabel(nextHandRequested, rebuyRequired, buyIn)) - } - } - } -} - @Composable private fun CardImage(index: Int?, modifier: Modifier) { Image( painter = painterResource(if (index != null) cardFace(index) else cardBack()), - contentDescription = null, - modifier = modifier.clip(RoundedCornerShape(4.dp)), + contentDescription = index?.let { Card(it).toString() } ?: "Face-down card", + contentScale = ContentScale.Fit, + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFFFFFDF8)), ) } diff --git a/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt b/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt index 21a26a4..03b4565 100644 --- a/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt @@ -78,6 +78,47 @@ class ActionButtonsTest { assertFalse(buttonsFor(offer(canRaise = false)).showRaise) } + @Test + fun `raise presets use the pot after calling and legal raise-to amounts`() { + val decision = offer( + toCall = 10, + minRaiseTo = 20, + maxRaiseTo = 500, + ) + + assertEquals( + listOf( + RaisePreset("Min", 20), + RaisePreset("½ pot", 35), + RaisePreset("Pot", 60), + RaisePreset("All in", 500), + ), + raisePresets(decision), + ) + } + + @Test + fun `raise presets drop clamped duplicates instead of mislabelling them`() { + val decision = offer( + toCall = 10, + minRaiseTo = 45, + maxRaiseTo = 50, + stack = 50, + ) + + assertEquals( + listOf(RaisePreset("Min", 45), RaisePreset("All in", 50)), + raisePresets(decision), + ) + } + + @Test + fun `a single legal shove has one all-in preset`() { + val decision = offer(minRaiseTo = 200, maxRaiseTo = 120, stack = 120) + + assertEquals(listOf(RaisePreset("All in", 120)), raisePresets(decision)) + } + // ---------- fold and call ---------- @Test diff --git a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt index 78d8f86..6075fc6 100644 --- a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt @@ -1,6 +1,11 @@ package com.jsjdesigns.poker +import com.jsjdesigns.poker.core.HandEvaluator +import com.jsjdesigns.poker.core.cardsOf import com.jsjdesigns.poker.game.HandResult +import com.jsjdesigns.poker.game.PotAward +import com.jsjdesigns.poker.game.PotPayout +import com.jsjdesigns.poker.game.ShowdownHand import com.jsjdesigns.poker.game.Street import com.jsjdesigns.poker.game.TableSnapshot import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,14 +25,41 @@ class HandSummaryTest { winners: List = listOf(1), showdown: Boolean = true, pot: Int = 16, - ) = HandResult( - board = intArrayOf(0, 1, 2, 3, 4), - net = net, - winners = winners, - wentToShowdown = showdown, - potSize = pot, - events = emptyList(), - ) + awards: List? = null, + ): HandResult { + val board = cardsOf("2c 7d 9s Jc 3h") + val holes = listOf(cardsOf("Ah Ad"), cardsOf("Kh Kd"), cardsOf("Qh Qd")) + return HandResult( + board = board, + net = net, + wentToShowdown = showdown, + potSize = pot, + events = emptyList(), + showdownHands = if (showdown) { + winners.map { winner -> + val hole = holes[winner] + ShowdownHand( + seat = winner, + hole = hole.toList(), + score = HandEvaluator.evaluate(hole + board), + ) + } + } else { + emptyList() + }, + potAwards = awards ?: listOf( + PotAward( + amount = pot, + payouts = winners.mapIndexed { index, winner -> + PotPayout( + winner, + pot / winners.size + if (index < pot % winners.size) 1 else 0, + ) + }, + ), + ), + ) + } @Test fun `summary names the winner and reports the hero's actual net`() { @@ -76,6 +108,43 @@ class HandSummaryTest { assertTrue(summary.heroWon) assertTrue(summary.detail.endsWith("You broke even")) } + + @Test + fun `side pots name each award and the winning made hand`() { + val board = cardsOf("2c 7d 9s Jc 3h") + val aces = cardsOf("Ah Ad") + val kings = cardsOf("Kh Kd") + val result = HandResult( + board = board, + net = intArrayOf(100, 100, -200), + wentToShowdown = true, + potSize = 450, + events = emptyList(), + showdownHands = listOf( + ShowdownHand(0, aces.toList(), HandEvaluator.evaluate(aces + board)), + ShowdownHand(1, kings.toList(), HandEvaluator.evaluate(kings + board)), + ), + potAwards = listOf( + PotAward(150, listOf(PotPayout(0, 150))), + PotAward(300, listOf(PotPayout(1, 300))), + ), + ) + + val summary = handSummaryFor( + handNumber = 11, + result = result, + seatNames = listOf("Jay", "Ada", "Bruno"), + ) + + assertEquals("Main pot", summary.awards[0].label) + assertEquals(150, summary.awards[0].amount) + assertEquals("You", summary.awards[0].winners.single().label) + assertEquals(150, summary.awards[0].winners.single().amountWon) + assertEquals("Pair", summary.awards[0].winners.single().handDescription) + assertEquals("Side pot 1", summary.awards[1].label) + assertEquals("Ada", summary.awards[1].winners.single().label) + assertEquals(kings.toList(), summary.awards[1].winners.single().hole) + } } class VisibleHandSummaryTest { @@ -87,6 +156,7 @@ class VisibleHandSummaryTest { potSize = 20, heroNet = -4, wentToShowdown = true, + awards = emptyList(), ) private fun snapshot( diff --git a/designs/Poker Table Polish.dc.html b/designs/Poker Table Polish.dc.html new file mode 100644 index 0000000..1150770 --- /dev/null +++ b/designs/Poker Table Polish.dc.html @@ -0,0 +1,455 @@ + + + + + + + + + + + + + + + + + +
+
+
Turn 1 · Hold'em table, portrait
+

Polished table, two bet-sizing directions

+

Same table treatment in both: oval felt with a rail, seats orbiting the pot instead of stacked in a row, chip stacks for live bets, a turn timer ring, bigger card faces, and the dead vertical space reclaimed. They differ only in how you size a raise — 1a keeps it always-visible, 1b hides it until you tap Raise and puts the slider under your thumb.

+
+ +
+ +
+
+ 1a + Always-on sizing rail +
+
+ +
+ 20:50 + + + + +
+ +
+
+
+
+ + + +
+
+
+ Hand 21 + FLOP +
+
+
+ + 1 / 2 +
+
+ +
+
+
+
+ +
+
+ POT + 28 +
+
+
+ Q + + +
+
+ 7 + + +
+
+ 3 + + +
+
+
+
+
+ + + + +
+
+ +
+
A
+
+ Ada + folded +
+
+ +
+
+
B
+
+ + +
+
+
+ Bruno + 304 +
+
+ + 6 +
+
+ +
+
C
+
+ Cleo + folded +
+
+ +
+
+
D
+
+ + +
+ D +
+
+ Dex + 613 +
+
+ + 6 +
+
+ +
+
+
+
E
+
+
+ + +
+
+
+ Enzo + 468 +
+
+ + 12 +
+
+ +
+ Enzo raised to 12 +
+ +
+
+
+ A + + +
+
+ K + + +
+
+
+ Jay + 220 + ACE HIGH · FLUSH DRAW +
+
+
+ +
+
+ RAISE TO + 36 +
+
+
+
+
+
+
+ + + + +
+
+ + + +
+
+
+

Sizing is always on screen, so raising is one tap. Costs ~90px of height — fine now that the table is an oval and the seats no longer eat a full band across the top.

+
+ +
+
+ 1b + Thumb-reach raise tray +
+
+ +
+ 20:50 + + + + +
+ +
+
+
+
+ + + +
+
+
+ Hand 21 + FLOP +
+
+
+ + 1 / 2 +
+
+ +
+
+
+
+ +
+
+ POT + 28 +
+
+
+ Q + + +
+
+ 7 + + +
+
+ 3 + + +
+
+
+
+
+ + + + +
+
+ +
+
A
+
+ Ada + folded +
+
+ +
+
+
B
+
+ + +
+
+
+ Bruno + 304 +
+
+ + 6 +
+
+ +
+
C
+
+ Cleo + folded +
+
+ +
+
+
D
+
+ + +
+ D +
+
+ Dex + 613 +
+
+ + 6 +
+
+ +
+
+
+
E
+
+
+ + +
+
+
+ Enzo + 468 +
+
+ + 12 +
+
+ +
+ 36 +
+
+
+
+
+
+ + + + +
+
+ +
+ Enzo raised to 12 +
+ +
+
+
+ A + + +
+
+ K + + +
+
+
+ Jay + 220 + ACE HIGH · FLUSH DRAW +
+
+
+ +
+ + + +
+
+

Tapping Raise opens a vertical tray at the right edge — the slider travels under your thumb instead of across the screen, and it costs zero permanent height. Second tap on Raise confirms.

+
+
+ +
+
+ What changed and why + Oval felt with a rail gives the screen a subject; seats orbit the pot so position (dealer button, who acts next) is readable. Folded players dim out instead of staying at full weight. Bets become chip stacks sitting between seat and pot, so you read the action spatially, not as a text list. +
+
+ Legibility + Hero cards are 76×106 with a single large pip and a clean corner index — no miniature pip grids. Board cards are 56×78 with dashed slots for undealt streets so the row never reflows. Stacks are gold, bets are white-on-chip, names are secondary. +
+
+ Still to add + Deal/flip/chip-slide animations, pot-collect on win, all-in and showdown states, sound + haptics on your turn, and the landscape variant (same parts, seats spread wider, action bar bottom-right). Say the word and I'll storyboard those next. +
+
+
+ +
+ + + diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt index bf03b24..71e3cc2 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -115,15 +115,57 @@ fun interface PlayerAgent { data class Pot(val amount: Int, val eligible: List) +/** One public hand turned face-up at showdown, with its authoritative engine score. */ +data class ShowdownHand( + val seat: Int, + val hole: List, + val score: Int, +) + +data class PotPayout( + val seat: Int, + val amount: Int, +) + +/** + * One settled pot in main-pot then side-pot order. + * + * A flat winner list cannot explain a side-pot hand: two players may both be + * winners without having tied or winning the same chips. Keeping each award + * and its exact payouts makes the completed-hand UI able to say exactly who won + * what, including the odd chip in a split. + */ +data class PotAward( + val amount: Int, + val payouts: List, +) { + init { + require(amount > 0) + require(payouts.isNotEmpty()) + require(payouts.all { it.amount > 0 }) + require(payouts.map { it.seat }.distinct().size == payouts.size) + require(payouts.sumOf { it.amount } == amount) + } + + val winnerSeats: List get() = payouts.map(PotPayout::seat) +} + data class HandResult( val board: IntArray, /** Net chip change per seat for this hand. */ val net: IntArray, - val winners: List, val wentToShowdown: Boolean, val potSize: Int, val events: List, -) + /** Empty for an uncontested hand; contains only cards publicly revealed at showdown. */ + val showdownHands: List, + /** Main pot first, then side pots from the shortest contribution upward. */ + val potAwards: List, +) { + /** Unique winning seats in main/side-pot award order. */ + val winners: List + get() = potAwards.flatMap(PotAward::winnerSeats).distinct() +} /** * A no-limit Texas Hold'em table. @@ -565,7 +607,8 @@ class Table( } val contenders = seats.filter { it.contesting } - val winners = ArrayList() + val showdownHands = ArrayList() + val potAwards = ArrayList() var wentToShowdown = false val potTotal = pot() @@ -573,7 +616,7 @@ class Table( val w = contenders.first() w.stack += potTotal for (s in seats) s.committedThisHand = 0 - winners.add(w.index) + potAwards.add(PotAward(potTotal, listOf(PotPayout(w.index, potTotal)))) } else { wentToShowdown = true // Cards are face up from here, so snapshots may show them. @@ -583,7 +626,15 @@ class Table( val seven = IntArray(7) seven[0] = c.hole[0]; seven[1] = c.hole[1] for (k in board.indices) seven[2 + k] = board[k] - scores[c.index] = HandEvaluator.evaluate(seven, 2 + board.size) + val score = HandEvaluator.evaluate(seven, 2 + board.size) + scores[c.index] = score + showdownHands.add( + ShowdownHand( + seat = c.index, + hole = c.hole.toList(), + score = score, + ), + ) } val levels = contenders.map { it.committedThisHand }.distinct().sorted() @@ -610,11 +661,16 @@ class Table( .filter { scores.getValue(it) == best } .sortedBy { (it - button - 1 + seats.size) % seats.size } val share = p.amount / potWinners.size - var remainder = p.amount - share * potWinners.size - for (w in potWinners) { - seats[w].stack += share - if (remainder > 0) { seats[w].stack += 1; remainder-- } - if (w !in winners) winners.add(w) + val remainder = p.amount - share * potWinners.size + val payouts = potWinners.mapIndexed { index, winner -> + PotPayout( + seat = winner, + amount = share + if (index < remainder) 1 else 0, + ) + } + potAwards.add(PotAward(p.amount, payouts)) + for (payout in payouts) { + seats[payout.seat].stack += payout.amount } } for (s in seats) s.committedThisHand = 0 @@ -624,10 +680,11 @@ class Table( return HandResult( board = board.toIntArray(), net = net, - winners = winners, wentToShowdown = wentToShowdown, potSize = potTotal, events = ArrayList(events), + showdownHands = showdownHands, + potAwards = potAwards, ) } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt index 117102d..f97eab7 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt @@ -182,6 +182,19 @@ class TableRulesTest { // Kings beat queens for the side pot. assertTrue(result.net[1] > 0, "kings should win the side pot") assertTrue(result.net[2] < 0, "queens should lose") + assertEquals( + listOf( + PotAward(150, listOf(PotPayout(0, 150))), + PotAward(300, listOf(PotPayout(1, 300))), + ), + result.potAwards, + "presentation must retain who won the main and side pots separately", + ) + assertEquals( + listOf(0, 1, 2), + result.showdownHands.map { it.seat }, + "only publicly revealed contenders belong in showdown results", + ) } @Test @@ -239,6 +252,16 @@ class TableRulesTest { assertEquals(3, result.net[2], "seat left of the button gets the odd chip") assertEquals(2, result.net[0], "the other winner gets the smaller share") assertEquals(-5, result.net[1], "the folded small blind loses its post") + assertEquals( + listOf(PotAward(25, listOf(PotPayout(2, 13), PotPayout(0, 12)))), + result.potAwards, + "award must preserve the exact odd-chip payout, not only the tied seats", + ) + assertEquals( + listOf(0, 2), + result.showdownHands.map { it.seat }, + "a folded player's private cards must not enter the public showdown result", + ) } // ---------- uncalled bets ----------