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 - Android is playable in Compose. Engine snapshots are paced through a
rendezvous channel, human decisions are matched by engine-owned tokens, and 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. 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 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 winner-focused pot/hand breakdown at completion. Face-up cards have fixed
legibility floors rather than shrinking on short displays; the opponent orbit legibility floors rather than shrinking on short displays; the opponent orbit
remains above the community-card band, and the terminal modal repeats the straddles the outside rail rather than occupying the community-card band.
board beside exact pot awards so dimming the table never hides the explanation. 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 - 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 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. 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 = fun raiseLabel(buttons: ActionButtons, amount: Int): String =
if (amount >= buttons.sliderMax) "All in" else "Raise $amount" 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. * 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.core.HandEvaluator
import com.jsjdesigns.poker.game.HandResult import com.jsjdesigns.poker.game.HandResult
import com.jsjdesigns.poker.game.PotAward
import com.jsjdesigns.poker.game.PotPayout
data class AwardWinner( data class AwardWinner(
val seat: Int, val seat: Int,
@@ -17,6 +19,15 @@ data class PotAwardSummary(
val winners: List<AwardWinner>, 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. * Immutable, presentation-ready outcome for one completed hand.
* *
@@ -35,6 +46,8 @@ data class HandSummary(
val potSize: Int, val potSize: Int,
val heroNet: Int, val heroNet: Int,
val wentToShowdown: Boolean, 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. */ /** Main/side-pot breakdown with public winning cards and evaluated hand names. */
val awards: List<PotAwardSummary>, val awards: List<PotAwardSummary>,
) { ) {
@@ -77,6 +90,22 @@ fun handSummaryFor(
} }
} }
val multiplePots = result.potAwards.size > 1 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 -> val awards = result.potAwards.mapIndexed { index, award ->
require(award.payouts.all { it.seat in seatNames.indices }) { require(award.payouts.all { it.seat in seatNames.indices }) {
"pot winner seat is outside the roster" "pot winner seat is outside the roster"
@@ -111,6 +140,7 @@ fun handSummaryFor(
potSize = result.potSize, potSize = result.potSize,
heroNet = result.net[heroSeat], heroNet = result.net[heroSeat],
wentToShowdown = result.wentToShowdown, wentToShowdown = result.wentToShowdown,
showdownPlayers = showdownPlayers,
awards = awards, awards = awards,
) )
} }
@@ -6,9 +6,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke 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.DecisionOffer
import com.jsjdesigns.poker.game.SeatSnapshot import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.TableSnapshot import com.jsjdesigns.poker.game.TableSnapshot
import kotlin.math.PI import kotlin.math.roundToInt
import kotlin.math.cos
import kotlin.math.sin
private val Night = Color(0xFF04100C) private val Night = Color(0xFF04100C)
private val TableBackground = Color(0xFF071D16) private val TableBackground = Color(0xFF071D16)
@@ -76,25 +75,37 @@ private val Muted = Color(0xFF9FB0A6)
private val Danger = Color(0xFF8C242A) private val Danger = Color(0xFF8C242A)
private val Panel = Color(0xE604120D) 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( internal data class NormalizedSeatPosition(
val centerX: Float, val centerX: Float,
val topY: 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 * 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> = internal fun opponentSeatOrbit(): List<NormalizedSeatPosition> =
listOf(145.0, 118.0, 90.0, 62.0, 35.0).map { degrees -> listOf(
val radians = degrees * PI / 180.0 NormalizedSeatPosition(centerX = 0.13f, topY = 0.18f),
NormalizedSeatPosition( NormalizedSeatPosition(centerX = 0.28f, topY = 0.01f),
centerX = (0.5 + 0.46 * cos(radians)).toFloat(), NormalizedSeatPosition(centerX = 0.50f, topY = 0f),
topY = (0.35 - 0.27 * sin(radians)).toFloat(), NormalizedSeatPosition(centerX = 0.72f, topY = 0.01f),
) NormalizedSeatPosition(centerX = 0.87f, topY = 0.18f),
} )
@Composable @Composable
fun TableScreen(vm: PokerViewModel) { fun TableScreen(vm: PokerViewModel) {
@@ -121,8 +132,9 @@ fun TableScreen(vm: PokerViewModel) {
handSummary = handSummary, handSummary = handSummary,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .aspectRatio(0.80f),
) )
Spacer(Modifier.weight(1f))
TableControls( TableControls(
offer = offer, offer = offer,
handSummary = handSummary, handSummary = handSummary,
@@ -157,13 +169,13 @@ private fun TableHeader(
"HAND ${snapshot?.handNumber ?: 1}", "HAND ${snapshot?.handNumber ?: 1}",
color = Cream, color = Cream,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 12.sp, fontSize = 14.sp,
) )
Text( Text(
snapshot?.street?.name.orEmpty(), snapshot?.street?.name.orEmpty(),
color = Muted.copy(alpha = 0.8f), color = Muted.copy(alpha = 0.8f),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 10.sp, fontSize = 11.sp,
letterSpacing = 1.4.sp, letterSpacing = 1.4.sp,
) )
} }
@@ -183,7 +195,7 @@ private fun TableHeader(
"${config?.bigBlind ?: DEFAULT_BIG_BLIND}", "${config?.bigBlind ?: DEFAULT_BIG_BLIND}",
color = TableGold, color = TableGold,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 11.sp, fontSize = 12.sp,
) )
} }
} }
@@ -206,8 +218,6 @@ private fun PokerTable(
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.padding(horizontal = 6.dp)
.clip(RoundedCornerShape(26.dp))
.background(TableBackground), .background(TableBackground),
) { ) {
TableFelt(Modifier.fillMaxSize()) TableFelt(Modifier.fillMaxSize())
@@ -216,14 +226,14 @@ private fun PokerTable(
seats.filter { it.index != HERO_SEAT } seats.filter { it.index != HERO_SEAT }
.forEachIndexed { index, seat -> .forEachIndexed { index, seat ->
val position = orbit.getOrElse(index) { orbit.last() } 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( OpponentSeat(
seat = seat, seat = seat,
isTurn = snapshot?.toAct == seat.index, isTurn = snapshot?.toAct == seat.index,
isWinner = seat.index in winners, isWinner = seat.index in winners,
modifier = Modifier.offset( modifier = Modifier.offset(
x = maxWidth * position.centerX - seatWidth / 2, x = maxWidth * position.centerX - seatWidth / 2,
y = maxHeight * position.topY, y = maxWidth * position.topY,
), ),
) )
} }
@@ -233,8 +243,8 @@ private fun PokerTable(
status = status, status = status,
tableTalk = tableTalk, tableTalk = tableTalk,
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.TopCenter)
.offset(y = 18.dp), .offset(y = maxWidth * 0.40f),
) )
HeroSeat( HeroSeat(
@@ -251,7 +261,7 @@ private fun PokerTable(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Night.copy(alpha = 0.82f)) .background(Night.copy(alpha = 0.82f))
.padding(18.dp), .padding(8.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
ShowdownModal(summary) ShowdownModal(summary)
@@ -263,40 +273,33 @@ private fun PokerTable(
@Composable @Composable
private fun TableFelt(modifier: Modifier = Modifier) { private fun TableFelt(modifier: Modifier = Modifier) {
Canvas(modifier) { Canvas(modifier) {
val railTop = 2.dp.toPx() val center = Offset(size.width * 0.5f, size.width * 0.5f)
val railBottom = 18.dp.toPx() val railRadius = size.minDimension * 0.405f
val horizontalBleed = 22.dp.toPx() drawCircle(
val railSize = Size(
width = size.width + horizontalBleed * 2,
height = size.height - railTop - railBottom,
)
val railOffset = Offset(-horizontalBleed, railTop)
drawOval(
brush = Brush.linearGradient( brush = Brush.linearGradient(
colors = listOf(RailLight, RailDark), colors = listOf(RailLight, RailDark),
start = railOffset, start = Offset(center.x - railRadius, center.y - railRadius),
end = Offset(size.width, size.height), end = Offset(size.width, size.height),
), ),
topLeft = railOffset, radius = railRadius,
size = railSize, center = center,
) )
val inset = 9.dp.toPx() val inset = 9.dp.toPx()
val feltOffset = Offset(railOffset.x + inset, railOffset.y + inset) val feltRadius = railRadius - inset
val feltSize = Size(railSize.width - inset * 2, railSize.height - inset * 2) drawCircle(
drawOval(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf(FeltLight, FeltMid, FeltDark), colors = listOf(FeltLight, FeltMid, FeltDark),
center = Offset(size.width * 0.5f, size.height * 0.38f), center = Offset(center.x, center.y - feltRadius * 0.18f),
radius = size.maxDimension * 0.67f, radius = feltRadius * 1.15f,
), ),
topLeft = feltOffset, radius = feltRadius,
size = feltSize, center = center,
) )
drawOval( drawCircle(
color = TableGold.copy(alpha = 0.34f), color = TableGold.copy(alpha = 0.34f),
topLeft = feltOffset, radius = feltRadius,
size = feltSize, center = center,
style = Stroke(width = 1.dp.toPx()), style = Stroke(width = 1.dp.toPx()),
) )
} }
@@ -311,14 +314,14 @@ private fun OpponentSeat(
) { ) {
val faded = seat.folded && !isWinner val faded = seat.folded && !isWinner
val revealed = seat.revealed && seat.hole != null val revealed = seat.revealed && seat.hole != null
val faceWidth = 52.dp val faceWidth = 50.dp
val faceHeight = 78.dp val faceHeight = 75.dp
Column( Column(
modifier = modifier 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), .alpha(if (faded) 0.38f else 1f),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
val highlight = when { val highlight = when {
@@ -333,12 +336,12 @@ private fun OpponentSeat(
color = highlight, color = highlight,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) )
.padding(3.dp), .padding(2.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val hole = seat.hole val hole = seat.hole
if (revealed && hole != null) { if (revealed && hole != null) {
Row(horizontalArrangement = Arrangement.spacedBy((-18).dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(2) { index -> repeat(2) { index ->
CardImage( CardImage(
hole.getOrNull(index), hole.getOrNull(index),
@@ -347,7 +350,19 @@ private fun OpponentSeat(
} }
} }
} else { } 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) { if (seat.isButton) {
@@ -373,7 +388,7 @@ private fun OpponentSeat(
seat.name, seat.name,
color = Cream, color = Cream,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 10.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -389,7 +404,7 @@ private fun OpponentSeat(
else -> TableGold.copy(alpha = 0.9f) else -> TableGold.copy(alpha = 0.9f)
}, },
fontWeight = if (seat.allIn || isWinner) FontWeight.Bold else FontWeight.Medium, 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) { private fun OpponentAvatar(seat: SeatSnapshot) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
Brush.linearGradient( Brush.linearGradient(
@@ -419,20 +434,8 @@ private fun OpponentAvatar(seat: SeatSnapshot) {
seat.name.take(1).uppercase(), seat.name.take(1).uppercase(),
color = GoldLight, color = GoldLight,
fontWeight = FontWeight.Bold, 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", seat?.name ?: "You",
color = Cream, color = Cream,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
fontSize = 14.sp, fontSize = 15.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -488,7 +491,7 @@ private fun HeroSeat(
"${seat?.stack ?: 0}", "${seat?.stack ?: 0}",
color = TableGold, color = TableGold,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 14.sp, fontSize = 15.sp,
) )
when { when {
folded -> StatusTag("FOLDED", Danger) folded -> StatusTag("FOLDED", Danger)
@@ -511,8 +514,8 @@ private fun BoardAndStatus(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val board = snapshot?.board.orEmpty() val board = snapshot?.board.orEmpty()
val cardWidth = 52.dp val cardWidth = 58.dp
val cardHeight = 78.dp val cardHeight = 87.dp
Column( Column(
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -548,12 +551,12 @@ private fun BoardAndStatus(
Text( Text(
"${it.speakerName}: “${it.text}", "${it.speakerName}: “${it.text}",
color = Cream.copy(alpha = 0.9f), color = Cream.copy(alpha = 0.9f),
fontSize = 11.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, 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)), border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.32f)),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 15.dp, vertical = 7.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
ChipStack(pot, chipSize = 18.dp)
Text( Text(
"POT", "POT",
color = TableGold.copy(alpha = 0.72f), color = TableGold.copy(alpha = 0.85f),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 9.sp, fontSize = 10.sp,
letterSpacing = 1.8.sp, letterSpacing = 1.8.sp,
) )
Text( Text(
@@ -602,13 +606,13 @@ private fun StatusPill(text: String, accent: Boolean) {
text, text,
color = if (accent) GoldLight else Cream.copy(alpha = 0.8f), color = if (accent) GoldLight else Cream.copy(alpha = 0.8f),
fontWeight = if (accent) FontWeight.Bold else FontWeight.Medium, fontWeight = if (accent) FontWeight.Bold else FontWeight.Medium,
fontSize = 10.sp, fontSize = 12.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.width(250.dp) .width(286.dp)
.padding(horizontal = 11.dp, vertical = 5.dp), .padding(horizontal = 11.dp, vertical = 7.dp),
) )
} }
} }
@@ -619,12 +623,12 @@ private fun BetMarker(amount: Int) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
ChipDot(Color(0xFFE9E7DD), 14.dp) ChipStack(amount, chipSize = 14.dp)
Text( Text(
"$amount", "$amount",
color = Cream.copy(alpha = 0.9f), color = Cream.copy(alpha = 0.9f),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 10.sp, fontSize = 12.sp,
) )
} }
} }
@@ -654,22 +658,50 @@ private fun StatusTag(text: String, color: Color) {
text, text,
color = color, color = color,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
fontSize = 9.sp, fontSize = 10.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp), 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 @Composable
private fun ChipDot(color: Color, size: Dp) { private fun ChipDot(color: Color, size: Dp) {
Canvas(Modifier.size(size)) { 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( drawCircle(
color = Color.White.copy(alpha = 0.48f), color = Color.White.copy(alpha = 0.62f),
radius = this.size.minDimension * 0.34f, radius = radius * 0.63f,
style = Stroke(width = 1.dp.toPx()), 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", "RAISE TO",
color = Muted.copy(alpha = 0.72f), color = Muted.copy(alpha = 0.72f),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 10.sp, fontSize = 12.sp,
letterSpacing = 1.4.sp, letterSpacing = 1.4.sp,
) )
Text( Text(
"$raiseTo", "$raiseTo",
color = GoldLight, color = GoldLight,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp, fontSize = 22.sp,
) )
} }
Slider( Row(
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(), modifier = Modifier.fillMaxWidth(),
onValueChange = { raiseTo = it.toInt() }, verticalAlignment = Alignment.CenterVertically,
valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(), horizontalArrangement = Arrangement.spacedBy(8.dp),
colors = SliderDefaults.colors( ) {
thumbColor = GoldLight, FineTuneButton("") {
activeTrackColor = TableGold, raiseTo = adjustRaiseAmount(raiseTo, -1, buttons)
inactiveTrackColor = Color.White.copy(alpha = 0.12f), }
), Slider(
modifier = Modifier.height(26.dp), 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) val presets = raisePresets(offer, buttons)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -780,8 +826,8 @@ private fun DecisionControls(
onClick = { raiseTo = preset.amount }, onClick = { raiseTo = preset.amount },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(32.dp), .height(42.dp),
contentPadding = ButtonDefaults.ContentPadding, contentPadding = PaddingValues(horizontal = 4.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (raiseTo == preset.amount) { containerColor = if (raiseTo == preset.amount) {
TableGold.copy(alpha = 0.2f) TableGold.copy(alpha = 0.2f)
@@ -795,7 +841,7 @@ private fun DecisionControls(
Text( Text(
"${preset.label} ${preset.amount}", "${preset.label} ${preset.amount}",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 9.sp, fontSize = 11.sp,
maxLines = 1, 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 @Composable
private fun ActionButton( private fun ActionButton(
title: String, title: String,
@@ -859,7 +924,7 @@ private fun ActionButton(
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier.height(54.dp), modifier = modifier.height(60.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content), colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content),
) { ) {
@@ -879,7 +944,7 @@ private fun ActionButton(
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.height(54.dp) .height(60.dp)
.background(container, RoundedCornerShape(14.dp)), .background(container, RoundedCornerShape(14.dp)),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@@ -894,9 +959,9 @@ private fun ActionButton(
@Composable @Composable
private fun ActionButtonText(title: String, detail: String?, color: Color) { private fun ActionButtonText(title: String, detail: String?, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { 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 { 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, shadowElevation = 14.dp,
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 15.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(7.dp),
) { ) {
Text( Text(
if (summary.wentToShowdown) "SHOWDOWN" else "HAND COMPLETE", if (summary.wentToShowdown) "SHOWDOWN" else "HAND COMPLETE",
@@ -949,21 +1014,41 @@ private fun ShowdownModal(summary: HandSummary) {
) )
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
summary.board.forEach { card -> summary.board.forEach { card ->
CardImage(card, Modifier.size(46.dp, 69.dp)) CardImage(card, Modifier.size(50.dp, 75.dp))
} }
} }
} }
} }
Row( if (summary.showdownPlayers.isNotEmpty()) {
modifier = Modifier Column(
.fillMaxWidth() horizontalAlignment = Alignment.CenterHorizontally,
.horizontalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), ) {
) { Text(
summary.awards.forEach { award -> "ALL SHOWDOWN HANDS",
PotAwardCard(award) 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 @Composable
private fun PotAwardCard(award: PotAwardSummary) { private fun ShowdownPlayerCard(player: ShowdownPlayerSummary) {
// 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()
Card( 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)), colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.07f)),
border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.38f)), border = androidx.compose.foundation.BorderStroke(1.dp, TableGold.copy(alpha = 0.38f)),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(10.dp),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (visibleCards.isNotEmpty()) { Text(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { "${award.label.uppercase()} ${award.amount}",
visibleCards.take(2).forEach { card -> color = TableGold,
CardImage(card, Modifier.size(76.dp, 114.dp)) fontWeight = FontWeight.ExtraBold,
} fontSize = 11.sp,
} letterSpacing = 0.4.sp,
} )
Column( Text(
award.winners.joinToString("") { "${it.label} ${it.amountWon}" },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp), color = Cream,
) { fontWeight = FontWeight.Bold,
Text( fontSize = 12.sp,
"${award.label.uppercase()} ${award.amount}", textAlign = TextAlign.End,
color = TableGold, maxLines = 1,
fontWeight = FontWeight.ExtraBold, overflow = TextOverflow.Ellipsis,
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,
)
}
}
} }
} }
} }
@@ -142,6 +142,16 @@ class ActionButtonsTest {
assertEquals("Raise 60", raiseLabel(b, 60)) assertEquals("Raise 60", raiseLabel(b, 60))
assertEquals("All in", raiseLabel(b, 500)) 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 { class CallCostTest {
@@ -146,6 +146,36 @@ class HandSummaryTest {
assertEquals("Ada", summary.awards[1].winners.single().label) assertEquals("Ada", summary.awards[1].winners.single().label)
assertEquals(kings.toList(), summary.awards[1].winners.single().hole) 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 { class VisibleHandSummaryTest {
@@ -158,6 +188,7 @@ class VisibleHandSummaryTest {
potSize = 20, potSize = 20,
heroNet = -4, heroNet = -4,
wentToShowdown = true, wentToShowdown = true,
showdownPlayers = emptyList(),
awards = emptyList(), awards = emptyList(),
) )
@@ -7,19 +7,29 @@ import org.junit.Test
class TableOrbitTest { class TableOrbitTest {
@Test @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() val positions = opponentSeatOrbit()
assertEquals(5, positions.size) assertEquals(5, positions.size)
assertTrue(positions.zipWithNext().all { (left, right) -> left.centerX < right.centerX }) assertTrue(positions.zipWithNext().all { (left, right) -> left.centerX < right.centerX })
assertTrue(positions.all { it.centerX in 0f..1f }) assertTrue(positions.all { it.centerX in 0f..1f })
assertTrue( assertTrue(
"opponent tops must stay in the upper fifth, leaving the middle to the board", "all opponents stay around the upper perimeter",
positions.all { it.topY < 0.21f }, positions.all { it.topY < 0.30f },
) )
assertEquals(1f, positions.first().centerX + positions.last().centerX, 0.001f) assertEquals(1f, positions.first().centerX + positions.last().centerX, 0.001f)
assertEquals(positions.first().topY, positions.last().topY, 0.001f) assertEquals(positions.first().topY, positions.last().topY, 0.001f)
assertEquals(1f, positions[1].centerX + positions[3].centerX, 0.001f) assertEquals(1f, positions[1].centerX + positions[3].centerX, 0.001f)
assertEquals(positions[1].topY, positions[3].topY, 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 })
}
} }