diff --git a/CLAUDE.md b/CLAUDE.md index d4d3d30..1ef324f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,12 +147,16 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - 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, + The portrait table follows the 1A design direction: circular felt, perimeter seats, spatial bets, large 2:3 card art, always-visible legal sizing controls, and a winner-focused pot/hand breakdown at completion. Face-up cards have fixed legibility floors rather than shrinking on short displays; the opponent orbit - remains above the community-card band, and the terminal modal repeats the - board beside exact pot awards so dimming the table never hides the explanation. + straddles the outside rail rather than occupying the community-card band. + The terminal modal repeats the board, every public hand turned up (including + losing hands), and exact pot awards so dimming the table never hides the + explanation. Raise sizing combines coarse slider/presets with one-chip minus + and plus controls; visible chip stacks grow with bets and the pot while numeric + labels remain authoritative. - 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 13fe439..a86cf09 100644 --- a/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt +++ b/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt @@ -61,6 +61,13 @@ fun buttonsFor(offer: DecisionOffer): ActionButtons { fun raiseLabel(buttons: ActionButtons, amount: Int): String = if (amount >= buttons.sliderMax) "All in" else "Raise $amount" +/** One-chip fine tuning for the sizing rail, clamped to the engine's legal range. */ +fun adjustRaiseAmount( + current: Int, + change: Int, + buttons: ActionButtons, +): Int = (current + change).coerceIn(buttons.sliderMin, buttons.sliderMax) + /** * Honest, legal raise-to shortcuts for the always-visible sizing rail. * diff --git a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt index 6477c33..46d027c 100644 --- a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt +++ b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt @@ -2,6 +2,8 @@ package com.jsjdesigns.poker import com.jsjdesigns.poker.core.HandEvaluator import com.jsjdesigns.poker.game.HandResult +import com.jsjdesigns.poker.game.PotAward +import com.jsjdesigns.poker.game.PotPayout data class AwardWinner( val seat: Int, @@ -17,6 +19,15 @@ data class PotAwardSummary( val winners: List, ) +data class ShowdownPlayerSummary( + val seat: Int, + val label: String, + val hole: List, + val handDescription: String, + /** Total won across every main/side pot; zero means the hand was shown but lost. */ + val amountWon: Int, +) + /** * Immutable, presentation-ready outcome for one completed hand. * @@ -35,6 +46,8 @@ data class HandSummary( val potSize: Int, val heroNet: Int, val wentToShowdown: Boolean, + /** Every public hand turned face-up, including contenders that won no pot. */ + val showdownPlayers: List, /** Main/side-pot breakdown with public winning cards and evaluated hand names. */ val awards: List, ) { @@ -77,6 +90,22 @@ fun handSummaryFor( } } val multiplePots = result.potAwards.size > 1 + val wonBySeat = result.potAwards + .flatMap(PotAward::payouts) + .groupBy(PotPayout::seat) + .mapValues { (_, payouts) -> payouts.sumOf(PotPayout::amount) } + val showdownPlayers = result.showdownHands.map { showdown -> + require(showdown.seat in seatNames.indices) { + "showdown seat is outside the roster" + } + ShowdownPlayerSummary( + seat = showdown.seat, + label = if (showdown.seat == heroSeat) "You" else seatNames[showdown.seat], + hole = showdown.hole.toList(), + handDescription = HandEvaluator.describe(showdown.score), + amountWon = wonBySeat[showdown.seat] ?: 0, + ) + } val awards = result.potAwards.mapIndexed { index, award -> require(award.payouts.all { it.seat in seatNames.indices }) { "pot winner seat is outside the roster" @@ -111,6 +140,7 @@ fun handSummaryFor( potSize = result.potSize, heroNet = result.net[heroSeat], wentToShowdown = result.wentToShowdown, + showdownPlayers = showdownPlayers, 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 5b1c9dc..38b9521 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -6,9 +6,11 @@ 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.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -41,7 +43,6 @@ 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 @@ -58,9 +59,7 @@ import com.jsjdesigns.poker.core.Card import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.SeatSnapshot import com.jsjdesigns.poker.game.TableSnapshot -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin +import kotlin.math.roundToInt private val Night = Color(0xFF04100C) private val TableBackground = Color(0xFF071D16) @@ -76,25 +75,37 @@ private val Muted = Color(0xFF9FB0A6) private val Danger = Color(0xFF8C242A) private val Panel = Color(0xE604120D) +internal fun visibleChipCount(amount: Int): Int = when { + amount <= 0 -> 0 + amount <= 2 -> 1 + amount <= 5 -> 2 + amount <= 10 -> 3 + amount <= 25 -> 4 + amount <= 60 -> 5 + amount <= 150 -> 6 + else -> 7 +} + internal data class NormalizedSeatPosition( val centerX: Float, val topY: Float, ) /** - * Five positions along the upper half of an ellipse. + * Five seats around the outside of a circular felt. * * Keeping this geometry separate from Compose makes the table relationship - * testable: opponents occupy the upper arc while the board owns the middle band. + * testable. The three top seats have independent horizontal lanes; the two side + * seats straddle the rail instead of consuming the board's middle. */ internal fun opponentSeatOrbit(): List = - listOf(145.0, 118.0, 90.0, 62.0, 35.0).map { degrees -> - val radians = degrees * PI / 180.0 - NormalizedSeatPosition( - centerX = (0.5 + 0.46 * cos(radians)).toFloat(), - topY = (0.35 - 0.27 * sin(radians)).toFloat(), - ) - } + listOf( + NormalizedSeatPosition(centerX = 0.13f, topY = 0.18f), + NormalizedSeatPosition(centerX = 0.28f, topY = 0.01f), + NormalizedSeatPosition(centerX = 0.50f, topY = 0f), + NormalizedSeatPosition(centerX = 0.72f, topY = 0.01f), + NormalizedSeatPosition(centerX = 0.87f, topY = 0.18f), + ) @Composable fun TableScreen(vm: PokerViewModel) { @@ -121,8 +132,9 @@ fun TableScreen(vm: PokerViewModel) { handSummary = handSummary, modifier = Modifier .fillMaxWidth() - .weight(1f), + .aspectRatio(0.80f), ) + Spacer(Modifier.weight(1f)) TableControls( offer = offer, handSummary = handSummary, @@ -157,13 +169,13 @@ private fun TableHeader( "HAND ${snapshot?.handNumber ?: 1}", color = Cream, fontWeight = FontWeight.Bold, - fontSize = 12.sp, + fontSize = 14.sp, ) Text( snapshot?.street?.name.orEmpty(), color = Muted.copy(alpha = 0.8f), fontWeight = FontWeight.Medium, - fontSize = 10.sp, + fontSize = 11.sp, letterSpacing = 1.4.sp, ) } @@ -183,7 +195,7 @@ private fun TableHeader( "${config?.bigBlind ?: DEFAULT_BIG_BLIND}", color = TableGold, fontWeight = FontWeight.Bold, - fontSize = 11.sp, + fontSize = 12.sp, ) } } @@ -206,8 +218,6 @@ private fun PokerTable( BoxWithConstraints( modifier = modifier - .padding(horizontal = 6.dp) - .clip(RoundedCornerShape(26.dp)) .background(TableBackground), ) { TableFelt(Modifier.fillMaxSize()) @@ -216,14 +226,14 @@ private fun PokerTable( seats.filter { it.index != HERO_SEAT } .forEachIndexed { index, seat -> val position = orbit.getOrElse(index) { orbit.last() } - val seatWidth = if (seat.revealed && seat.hole != null) 90.dp else 76.dp + val seatWidth = if (seat.revealed && seat.hole != null) 108.dp else 88.dp OpponentSeat( seat = seat, isTurn = snapshot?.toAct == seat.index, isWinner = seat.index in winners, modifier = Modifier.offset( x = maxWidth * position.centerX - seatWidth / 2, - y = maxHeight * position.topY, + y = maxWidth * position.topY, ), ) } @@ -233,8 +243,8 @@ private fun PokerTable( status = status, tableTalk = tableTalk, modifier = Modifier - .align(Alignment.Center) - .offset(y = 18.dp), + .align(Alignment.TopCenter) + .offset(y = maxWidth * 0.40f), ) HeroSeat( @@ -251,7 +261,7 @@ private fun PokerTable( modifier = Modifier .fillMaxSize() .background(Night.copy(alpha = 0.82f)) - .padding(18.dp), + .padding(8.dp), contentAlignment = Alignment.Center, ) { ShowdownModal(summary) @@ -263,40 +273,33 @@ private fun PokerTable( @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( + val center = Offset(size.width * 0.5f, size.width * 0.5f) + val railRadius = size.minDimension * 0.405f + drawCircle( brush = Brush.linearGradient( colors = listOf(RailLight, RailDark), - start = railOffset, + start = Offset(center.x - railRadius, center.y - railRadius), end = Offset(size.width, size.height), ), - topLeft = railOffset, - size = railSize, + radius = railRadius, + center = center, ) 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( + val feltRadius = railRadius - inset + drawCircle( brush = Brush.radialGradient( colors = listOf(FeltLight, FeltMid, FeltDark), - center = Offset(size.width * 0.5f, size.height * 0.38f), - radius = size.maxDimension * 0.67f, + center = Offset(center.x, center.y - feltRadius * 0.18f), + radius = feltRadius * 1.15f, ), - topLeft = feltOffset, - size = feltSize, + radius = feltRadius, + center = center, ) - drawOval( + drawCircle( color = TableGold.copy(alpha = 0.34f), - topLeft = feltOffset, - size = feltSize, + radius = feltRadius, + center = center, style = Stroke(width = 1.dp.toPx()), ) } @@ -311,14 +314,14 @@ private fun OpponentSeat( ) { val faded = seat.folded && !isWinner val revealed = seat.revealed && seat.hole != null - val faceWidth = 52.dp - val faceHeight = 78.dp + val faceWidth = 50.dp + val faceHeight = 75.dp Column( modifier = modifier - .width(if (revealed) 90.dp else 76.dp) + .width(if (revealed) 108.dp else 88.dp) .alpha(if (faded) 0.38f else 1f), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box(contentAlignment = Alignment.Center) { val highlight = when { @@ -333,12 +336,12 @@ private fun OpponentSeat( color = highlight, shape = RoundedCornerShape(12.dp), ) - .padding(3.dp), + .padding(2.dp), contentAlignment = Alignment.Center, ) { val hole = seat.hole if (revealed && hole != null) { - Row(horizontalArrangement = Arrangement.spacedBy((-18).dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { repeat(2) { index -> CardImage( hole.getOrNull(index), @@ -347,7 +350,19 @@ private fun OpponentSeat( } } } else { - OpponentAvatar(seat) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + OpponentAvatar(seat) + if (!seat.folded) { + Row(horizontalArrangement = Arrangement.spacedBy((-5).dp)) { + repeat(2) { + CardImage(null, Modifier.size(18.dp, 27.dp)) + } + } + } + } } } if (seat.isButton) { @@ -373,7 +388,7 @@ private fun OpponentSeat( seat.name, color = Cream, fontWeight = FontWeight.Bold, - fontSize = 10.sp, + fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -389,7 +404,7 @@ private fun OpponentSeat( else -> TableGold.copy(alpha = 0.9f) }, fontWeight = if (seat.allIn || isWinner) FontWeight.Bold else FontWeight.Medium, - fontSize = 10.sp, + fontSize = 12.sp, ) } } @@ -406,7 +421,7 @@ private fun OpponentSeat( private fun OpponentAvatar(seat: SeatSnapshot) { Box( modifier = Modifier - .size(44.dp) + .size(48.dp) .clip(CircleShape) .background( Brush.linearGradient( @@ -419,20 +434,8 @@ private fun OpponentAvatar(seat: SeatSnapshot) { seat.name.take(1).uppercase(), color = GoldLight, fontWeight = FontWeight.Bold, - fontSize = 16.sp, + fontSize = 18.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)) - } - } - } } } @@ -480,7 +483,7 @@ private fun HeroSeat( seat?.name ?: "You", color = Cream, fontWeight = FontWeight.ExtraBold, - fontSize = 14.sp, + fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -488,7 +491,7 @@ private fun HeroSeat( "${seat?.stack ?: 0}", color = TableGold, fontWeight = FontWeight.Bold, - fontSize = 14.sp, + fontSize = 15.sp, ) when { folded -> StatusTag("FOLDED", Danger) @@ -511,8 +514,8 @@ private fun BoardAndStatus( modifier: Modifier = Modifier, ) { val board = snapshot?.board.orEmpty() - val cardWidth = 52.dp - val cardHeight = 78.dp + val cardWidth = 58.dp + val cardHeight = 87.dp Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -548,12 +551,12 @@ private fun BoardAndStatus( Text( "${it.speakerName}: “${it.text}”", color = Cream.copy(alpha = 0.9f), - fontSize = 11.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(260.dp), + modifier = Modifier.width(292.dp), ) } } @@ -567,15 +570,16 @@ private fun PotPill(pot: Int) { border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.32f)), ) { Row( - modifier = Modifier.padding(horizontal = 15.dp, vertical = 7.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + ChipStack(pot, chipSize = 18.dp) Text( "POT", - color = TableGold.copy(alpha = 0.72f), + color = TableGold.copy(alpha = 0.85f), fontWeight = FontWeight.Bold, - fontSize = 9.sp, + fontSize = 10.sp, letterSpacing = 1.8.sp, ) Text( @@ -602,13 +606,13 @@ private fun StatusPill(text: String, accent: Boolean) { text, color = if (accent) GoldLight else Cream.copy(alpha = 0.8f), fontWeight = if (accent) FontWeight.Bold else FontWeight.Medium, - fontSize = 10.sp, + fontSize = 12.sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier - .width(250.dp) - .padding(horizontal = 11.dp, vertical = 5.dp), + .width(286.dp) + .padding(horizontal = 11.dp, vertical = 7.dp), ) } } @@ -619,12 +623,12 @@ private fun BetMarker(amount: Int) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - ChipDot(Color(0xFFE9E7DD), 14.dp) + ChipStack(amount, chipSize = 14.dp) Text( "$amount", color = Cream.copy(alpha = 0.9f), fontWeight = FontWeight.Bold, - fontSize = 10.sp, + fontSize = 12.sp, ) } } @@ -654,22 +658,50 @@ private fun StatusTag(text: String, color: Color) { text, color = color, fontWeight = FontWeight.ExtraBold, - fontSize = 9.sp, + fontSize = 10.sp, letterSpacing = 0.5.sp, modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp), ) } } +@Composable +private fun ChipStack(amount: Int, chipSize: Dp) { + val colors = listOf( + Color(0xFFE7E4D9), + Color(0xFF2A9E6B), + Color(0xFF2F72C4), + Color(0xFFE2B85B), + Color(0xFFB94B52), + ) + Row( + horizontalArrangement = Arrangement.spacedBy((-4).dp), + verticalAlignment = Alignment.Bottom, + ) { + repeat(visibleChipCount(amount)) { index -> + Box(Modifier.padding(bottom = ((index % 3) * 3).dp)) { + ChipDot(colors[index % colors.size], chipSize) + } + } + } +} + @Composable private fun ChipDot(color: Color, size: Dp) { Canvas(Modifier.size(size)) { - drawCircle(color = color) + val radius = this.size.minDimension * 0.48f + drawCircle(color = Color.Black.copy(alpha = 0.34f), radius = radius) + drawCircle(color = color, radius = radius * 0.91f) drawCircle( - color = Color.White.copy(alpha = 0.48f), - radius = this.size.minDimension * 0.34f, - style = Stroke(width = 1.dp.toPx()), + color = Color.White.copy(alpha = 0.62f), + radius = radius * 0.63f, + style = Stroke(width = 1.2.dp.toPx()), ) + val mark = radius * 0.63f + drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x, center.y - radius), Offset(center.x, center.y - mark), 1.5.dp.toPx()) + drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x, center.y + mark), Offset(center.x, center.y + radius), 1.5.dp.toPx()) + drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x - radius, center.y), Offset(center.x - mark, center.y), 1.5.dp.toPx()) + drawLine(Color.White.copy(alpha = 0.78f), Offset(center.x + mark, center.y), Offset(center.x + radius, center.y), 1.5.dp.toPx()) } } @@ -749,27 +781,41 @@ private fun DecisionControls( "RAISE TO", color = Muted.copy(alpha = 0.72f), fontWeight = FontWeight.Bold, - fontSize = 10.sp, + fontSize = 12.sp, letterSpacing = 1.4.sp, ) Text( "$raiseTo", color = GoldLight, fontWeight = FontWeight.ExtraBold, - fontSize = 19.sp, + fontSize = 22.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 = GoldLight, - activeTrackColor = TableGold, - inactiveTrackColor = Color.White.copy(alpha = 0.12f), - ), - modifier = Modifier.height(26.dp), - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FineTuneButton("−") { + raiseTo = adjustRaiseAmount(raiseTo, -1, buttons) + } + Slider( + value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(), + onValueChange = { raiseTo = it.roundToInt() }, + valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(), + colors = SliderDefaults.colors( + thumbColor = GoldLight, + activeTrackColor = TableGold, + inactiveTrackColor = Color.White.copy(alpha = 0.12f), + ), + modifier = Modifier + .weight(1f) + .height(44.dp), + ) + FineTuneButton("+") { + raiseTo = adjustRaiseAmount(raiseTo, 1, buttons) + } + } val presets = raisePresets(offer, buttons) Row( modifier = Modifier.fillMaxWidth(), @@ -780,8 +826,8 @@ private fun DecisionControls( onClick = { raiseTo = preset.amount }, modifier = Modifier .weight(1f) - .height(32.dp), - contentPadding = ButtonDefaults.ContentPadding, + .height(42.dp), + contentPadding = PaddingValues(horizontal = 4.dp), colors = ButtonDefaults.buttonColors( containerColor = if (raiseTo == preset.amount) { TableGold.copy(alpha = 0.2f) @@ -795,7 +841,7 @@ private fun DecisionControls( Text( "${preset.label} ${preset.amount}", fontWeight = FontWeight.Bold, - fontSize = 9.sp, + fontSize = 11.sp, maxLines = 1, ) } @@ -848,6 +894,25 @@ private fun DecisionControls( } } +@Composable +private fun FineTuneButton( + label: String, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = Modifier.size(44.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.09f), + contentColor = GoldLight, + ), + shape = RoundedCornerShape(12.dp), + ) { + Text(label, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp) + } +} + @Composable private fun ActionButton( title: String, @@ -859,7 +924,7 @@ private fun ActionButton( ) { Button( onClick = onClick, - modifier = modifier.height(54.dp), + modifier = modifier.height(60.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content), ) { @@ -879,7 +944,7 @@ private fun ActionButton( Button( onClick = onClick, modifier = modifier - .height(54.dp) + .height(60.dp) .background(container, RoundedCornerShape(14.dp)), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors( @@ -894,9 +959,9 @@ private fun ActionButton( @Composable private fun ActionButtonText(title: String, detail: String?, color: Color) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(title, color = color, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp) + Text(title, color = color, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp) detail?.let { - Text(it, color = color.copy(alpha = 0.68f), fontWeight = FontWeight.Bold, fontSize = 10.sp) + Text(it, color = color.copy(alpha = 0.78f), fontWeight = FontWeight.Bold, fontSize = 12.sp) } } } @@ -911,9 +976,9 @@ private fun ShowdownModal(summary: HandSummary) { shadowElevation = 14.dp, ) { Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 15.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), ) { Text( if (summary.wentToShowdown) "SHOWDOWN" else "HAND COMPLETE", @@ -949,21 +1014,41 @@ private fun ShowdownModal(summary: HandSummary) { ) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { summary.board.forEach { card -> - CardImage(card, Modifier.size(46.dp, 69.dp)) + CardImage(card, Modifier.size(50.dp, 75.dp)) } } } } - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - summary.awards.forEach { award -> - PotAwardCard(award) + if (summary.showdownPlayers.isNotEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "ALL SHOWDOWN HANDS", + color = Muted.copy(alpha = 0.9f), + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + letterSpacing = 1.2.sp, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + summary.showdownPlayers.forEach { player -> + ShowdownPlayerCard(player) + } + } } } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + summary.awards.forEach { award -> PotAwardCard(award) } + } } } } @@ -1014,67 +1099,88 @@ private fun CompletionControls( } @Composable -private fun PotAwardCard(award: PotAwardSummary) { - // For a split pot, showing only the first winner's cards would imply those - // cards alone explain the award. The modal retains the board and names every - // tied winner with their exact payout instead. - val visibleCards = award.winners - .singleOrNull() - ?.hole - .orEmpty() +private fun ShowdownPlayerCard(player: ShowdownPlayerSummary) { Card( - modifier = Modifier.width(310.dp), + modifier = Modifier.width(118.dp), + colors = CardDefaults.cardColors( + containerColor = if (player.amountWon > 0) { + TableGold.copy(alpha = 0.13f) + } else { + Color.White.copy(alpha = 0.06f) + }, + ), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + if (player.amountWon > 0) TableGold.copy(alpha = 0.62f) else Color.White.copy(alpha = 0.1f), + ), + shape = RoundedCornerShape(12.dp), + ) { + Column( + modifier = Modifier.padding(7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + player.hole.take(2).forEach { card -> + CardImage(card, Modifier.size(48.dp, 72.dp)) + } + } + Text( + player.label, + color = Cream, + fontWeight = FontWeight.ExtraBold, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + player.handDescription, + color = if (player.amountWon > 0) GoldLight else Cream.copy(alpha = 0.84f), + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (player.amountWon > 0) "WON ${player.amountWon}" else "SHOWED", + color = if (player.amountWon > 0) TableGold else Muted, + fontWeight = FontWeight.ExtraBold, + fontSize = 10.sp, + ) + } + } +} + +@Composable +private fun PotAwardCard(award: PotAwardSummary) { + Card( + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)), border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.38f)), - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(10.dp), ) { Row( - modifier = Modifier.padding(horizontal = 11.dp, vertical = 10.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (visibleCards.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - visibleCards.take(2).forEach { card -> - CardImage(card, Modifier.size(76.dp, 114.dp)) - } - } - } - Column( + Text( + "${award.label.uppercase()} ${award.amount}", + color = TableGold, + fontWeight = FontWeight.ExtraBold, + fontSize = 11.sp, + letterSpacing = 0.4.sp, + ) + Text( + award.winners.joinToString(" • ") { "${it.label} ${it.amountWon}" }, modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - "${award.label.uppercase()} ${award.amount}", - color = TableGold, - fontWeight = FontWeight.ExtraBold, - fontSize = 11.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.ExtraBold, - fontSize = 16.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val descriptions = award.winners.mapNotNull(AwardWinner::handDescription).distinct() - if (descriptions.isNotEmpty()) { - Text( - descriptions.joinToString(" / "), - color = GoldLight, - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + color = Cream, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } diff --git a/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt b/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt index 03b4565..c46b537 100644 --- a/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/ActionButtonsTest.kt @@ -142,6 +142,16 @@ class ActionButtonsTest { assertEquals("Raise 60", raiseLabel(b, 60)) assertEquals("All in", raiseLabel(b, 500)) } + + @Test + fun `fine tuning moves one chip and cannot leave the legal range`() { + val b = buttonsFor(offer(minRaiseTo = 20, maxRaiseTo = 500)) + + assertEquals(59, adjustRaiseAmount(60, -1, b)) + assertEquals(61, adjustRaiseAmount(60, 1, b)) + assertEquals(20, adjustRaiseAmount(20, -1, b)) + assertEquals(500, adjustRaiseAmount(500, 1, b)) + } } class CallCostTest { diff --git a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt index 8a2d297..d4c1725 100644 --- a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt @@ -146,6 +146,36 @@ class HandSummaryTest { assertEquals("Ada", summary.awards[1].winners.single().label) assertEquals(kings.toList(), summary.awards[1].winners.single().hole) } + + @Test + fun `showdown lists every public contender including hands that lost`() { + 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(10, -10), + wentToShowdown = true, + potSize = 20, + events = emptyList(), + showdownHands = listOf( + ShowdownHand(0, aces.toList(), HandEvaluator.evaluate(aces + board)), + ShowdownHand(1, kings.toList(), HandEvaluator.evaluate(kings + board)), + ), + potAwards = listOf(PotAward(20, listOf(PotPayout(0, 20)))), + ) + + val summary = handSummaryFor( + handNumber = 12, + result = result, + seatNames = listOf("Jay", "Ada"), + ) + + assertEquals(listOf("You", "Ada"), summary.showdownPlayers.map { it.label }) + assertEquals(listOf(20, 0), summary.showdownPlayers.map { it.amountWon }) + assertEquals(listOf("Pair", "Pair"), summary.showdownPlayers.map { it.handDescription }) + assertEquals(kings.toList(), summary.showdownPlayers[1].hole) + } } class VisibleHandSummaryTest { @@ -158,6 +188,7 @@ class VisibleHandSummaryTest { potSize = 20, heroNet = -4, wentToShowdown = true, + showdownPlayers = emptyList(), awards = emptyList(), ) diff --git a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt index 897a9fc..fd278b0 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableOrbitTest.kt @@ -7,19 +7,29 @@ import org.junit.Test class TableOrbitTest { @Test - fun `five opponents form a symmetric upper ellipse above the board band`() { + fun `five opponents form a symmetric perimeter around the circular felt`() { val positions = opponentSeatOrbit() assertEquals(5, positions.size) assertTrue(positions.zipWithNext().all { (left, right) -> left.centerX < right.centerX }) assertTrue(positions.all { it.centerX in 0f..1f }) assertTrue( - "opponent tops must stay in the upper fifth, leaving the middle to the board", - positions.all { it.topY < 0.21f }, + "all opponents stay around the upper perimeter", + positions.all { it.topY < 0.30f }, ) assertEquals(1f, positions.first().centerX + positions.last().centerX, 0.001f) assertEquals(positions.first().topY, positions.last().topY, 0.001f) assertEquals(1f, positions[1].centerX + positions[3].centerX, 0.001f) assertEquals(positions[1].topY, positions[3].topY, 0.001f) } + + @Test + fun `visible chips accumulate monotonically but remain bounded`() { + val amounts = listOf(0, 1, 3, 7, 20, 50, 100, 500) + val counts = amounts.map(::visibleChipCount) + + assertEquals(0, counts.first()) + assertEquals(7, counts.last()) + assertTrue(counts.zipWithNext().all { (left, right) -> left <= right }) + } }