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
+7 -3
View File
@@ -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.
@@ -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 })
}
}