Rework table spacing and betting controls

This commit is contained in:
Jay
2026-07-27 10:24:50 -04:00
parent 7268d62790
commit 33be35f970
7 changed files with 377 additions and 179 deletions
@@ -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.
*
@@ -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<AwardWinner>,
)
data class ShowdownPlayerSummary(
val seat: Int,
val label: String,
val hole: List<Int>,
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<ShowdownPlayerSummary>,
/** Main/side-pot breakdown with public winning cards and evaluated hand names. */
val awards: List<PotAwardSummary>,
) {
@@ -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,
)
}
@@ -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<NormalizedSeatPosition> =
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,
)
}
}
}
@@ -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 {
@@ -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(),
)
@@ -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 })
}
}