Pause between hands and show results

This commit is contained in:
Jay
2026-07-26 11:11:48 -04:00
parent db1f96b421
commit 9eb77a27a2
6 changed files with 359 additions and 13 deletions
@@ -0,0 +1,55 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.game.HandResult
/**
* Immutable, presentation-ready outcome for one completed hand.
*
* This is built from [HandResult], not inferred from the final table snapshot:
* side pots can produce several winners and a player's net can differ from the
* amount of any pot they won.
*/
data class HandSummary(
val handNumber: Int,
val winnerNames: List<String>,
val potSize: Int,
val heroNet: Int,
val wentToShowdown: Boolean,
) {
val title: String
get() = when {
winnerNames.size == 1 && winnerNames.single() == "You" -> "You win"
winnerNames.size == 1 -> "${winnerNames.single()} wins"
else -> "${winnerNames.joinToString(" & ")} win"
}
val detail: String
get() {
val ending = when {
heroNet > 0 -> "You won $heroNet"
heroNet < 0 -> "You lost ${-heroNet}"
else -> "You broke even"
}
val finish = if (wentToShowdown) "Showdown" else "Uncontested"
return "$finish • Pot $potSize$ending"
}
}
fun handSummaryFor(
handNumber: Int,
result: HandResult,
seatNames: List<String>,
heroSeat: Int = HERO_SEAT,
): HandSummary {
require(heroSeat in result.net.indices) { "hero seat is outside the result" }
require(result.winners.isNotEmpty()) { "a completed hand must have a winner" }
require(result.winners.all { it in seatNames.indices }) { "winner seat is outside the roster" }
return HandSummary(
handNumber = handNumber,
winnerNames = result.winners.map(seatNames::get),
potSize = result.potSize,
heroNet = result.net[heroSeat],
wentToShowdown = result.wentToShowdown,
)
}
@@ -0,0 +1,23 @@
package com.jsjdesigns.poker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
/**
* A hand-numbered continuation gate.
*
* Remembering the greatest requested hand makes repeated taps idempotent: two
* taps on hand 4 can release hand 4 only, never a future hand 5.
*/
internal class NextHandGate {
private val requestedThrough = MutableStateFlow(0)
fun request(completedHand: Int) {
requestedThrough.update { maxOf(it, completedHand) }
}
suspend fun awaitRequest(completedHand: Int) {
requestedThrough.first { it >= completedHand }
}
}
@@ -31,6 +31,8 @@ private const val BIG_BLIND = 2
data class UiState(
val snapshot: TableSnapshot? = null,
val handsPlayed: Int = 0,
val handSummary: HandSummary? = null,
val nextHandRequested: Boolean = false,
val message: String? = null,
) {
/**
@@ -49,6 +51,18 @@ data class UiState(
// is ambiguous and would briefly pair a new offer with a stale board.
return offer.takeIf { snap.toActToken == it.token }
}
/**
* A result belongs on screen only beside the terminal frame for that hand.
* This keeps a delayed result or a new deal from being paired with the wrong
* board in exactly the same way [liveOffer] protects decisions.
*/
fun visibleHandSummary(): HandSummary? {
val snap = snapshot ?: return null
val terminal = snap.phase == TableSnapshot.Phase.SHOWDOWN ||
snap.phase == TableSnapshot.Phase.COMPLETE
return handSummary.takeIf { terminal && it?.handNumber == snap.handNumber }
}
}
/**
@@ -66,6 +80,7 @@ data class UiState(
class PokerViewModel : ViewModel() {
private val human = HumanAgent()
private val nextHandGate = NextHandGate()
// RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of
// frames ahead of the animation, so the board on screen and the action being
// offered could belong to different moments — even different hands. With no
@@ -110,7 +125,17 @@ class PokerViewModel : ViewModel() {
private suspend fun consumeFrames() {
for (frame in frames) {
_state.update { it.copy(snapshot = frame.maskedFor(HERO_SEAT)) }
_state.update { current ->
val sameCompletedHand = current.handSummary?.handNumber == frame.handNumber
current.copy(
snapshot = frame.maskedFor(HERO_SEAT),
// Keep the result visible after the tap until the new deal
// actually reaches the screen. This avoids flashing a stale
// terminal board with a generic "Waiting…" message.
handSummary = current.handSummary.takeIf { sameCompletedHand },
nextHandRequested = current.nextHandRequested && sameCompletedHand,
)
}
delay(pacingMillis(frame))
}
}
@@ -135,11 +160,41 @@ class PokerViewModel : ViewModel() {
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
table.advanceButton()
runCatching { table.playHand() }
.onSuccess { _state.update { s -> s.copy(handsPlayed = s.handsPlayed + 1) } }
.onSuccess { result ->
val summary = handSummaryFor(
handNumber = table.handNumber,
result = result,
seatNames = seats.map(Seat::name),
)
_state.update {
it.copy(
handsPlayed = it.handsPlayed + 1,
handSummary = summary,
nextHandRequested = false,
)
}
// Do not let the next deal erase the outcome before the
// player has read it and chosen to continue.
nextHandGate.awaitRequest(summary.handNumber)
}
.onFailure { return } // scope cancelled: the screen went away
}
}
/**
* Advances only the completed hand currently on screen.
*
* The numbered gate makes a double tap harmless, while the state check drops
* a stale callback after a configuration change or new deal.
*/
fun nextHand(completedHand: Int) {
val current = _state.value
val summary = current.visibleHandSummary() ?: return
if (summary.handNumber != completedHand || current.nextHandRequested) return
_state.update { it.copy(nextHandRequested = true) }
nextHandGate.request(completedHand)
}
/**
* Submits against the token the button was rendered from, so a stale or
* doubled tap is dropped rather than applied to whatever comes next.
@@ -19,6 +19,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -48,6 +49,7 @@ fun TableScreen(vm: PokerViewModel) {
val snap = state.snapshot
// Only show an action bar that belongs to the table currently on screen.
val offer = state.liveOffer(rawOffer)
val handSummary = state.visibleHandSummary()
Column(
modifier = Modifier
@@ -122,10 +124,13 @@ fun TableScreen(vm: PokerViewModel) {
ActionBar(
offer = offer,
handSummary = handSummary,
nextHandRequested = state.nextHandRequested,
heroFolded = hero?.folded == true,
onFold = vm::fold,
onCheckCall = vm::checkOrCall,
onRaise = vm::raiseTo,
onNextHand = vm::nextHand,
)
}
}
@@ -217,17 +222,28 @@ private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
@Composable
private fun ActionBar(
offer: com.jsjdesigns.poker.game.DecisionOffer?,
handSummary: HandSummary?,
nextHandRequested: Boolean,
heroFolded: Boolean,
onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit,
onRaise: (Long, Int) -> Unit,
onNextHand: (Int) -> Unit,
) {
if (offer == null) {
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
Text(
if (heroFolded) "You folded — sitting out this hand" else "Waiting…",
color = Color.White.copy(alpha = 0.4f),
if (handSummary != null) {
HandResultBar(
summary = handSummary,
nextHandRequested = nextHandRequested,
onNextHand = onNextHand,
)
} else {
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
Text(
if (heroFolded) "You folded — sitting out this hand" else "Waiting…",
color = Color.White.copy(alpha = 0.4f),
)
}
}
return
}
@@ -244,6 +260,11 @@ private fun ActionBar(
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(),
onValueChange = { raiseTo = it.toInt() },
valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(),
colors = SliderDefaults.colors(
thumbColor = Color(0xFFE3C179),
activeTrackColor = Color(0xFFE3C179),
inactiveTrackColor = Color.White.copy(alpha = 0.22f),
),
)
}
Row(
@@ -254,14 +275,20 @@ private fun ActionBar(
Button(
onClick = { onFold(offer.token) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8F1218),
contentColor = Color.White,
),
) { Text("Fold") }
}
Button(
onClick = { onCheckCall(offer.token) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2C6E49),
contentColor = Color.White,
),
) { Text(buttons.checkOrCallLabel) }
if (buttons.showRaise) {
@@ -273,13 +300,59 @@ private fun ActionBar(
Button(
onClick = { onRaise(offer.token, amount) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF14357A),
contentColor = Color.White,
),
) { Text(raiseLabel(buttons, amount)) }
}
}
}
}
@Composable
private fun HandResultBar(
summary: HandSummary,
nextHandRequested: Boolean,
onNextHand: (Int) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.09f)),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
summary.title,
color = Color(0xFFE3C179),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
Text(
summary.detail,
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp,
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { onNextHand(summary.handNumber) },
enabled = !nextHandRequested,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2C6E49),
contentColor = Color.White,
disabledContainerColor = Color(0xFF2C6E49).copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.65f),
),
) {
Text(if (nextHandRequested) "Dealing…" else "Next hand")
}
}
}
}
@Composable
private fun CardImage(index: Int?, modifier: Modifier) {
Image(
@@ -0,0 +1,140 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.game.HandResult
import com.jsjdesigns.poker.game.Street
import com.jsjdesigns.poker.game.TableSnapshot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HandSummaryTest {
private fun result(
net: IntArray = intArrayOf(-8, 8, 0),
winners: List<Int> = listOf(1),
showdown: Boolean = true,
pot: Int = 16,
) = HandResult(
board = intArrayOf(0, 1, 2, 3, 4),
net = net,
winners = winners,
wentToShowdown = showdown,
potSize = pot,
events = emptyList(),
)
@Test
fun `summary names the winner and reports the hero's actual net`() {
val summary = handSummaryFor(
handNumber = 7,
result = result(),
seatNames = listOf("You", "Ada", "Bruno"),
)
assertEquals("Ada wins", summary.title)
assertEquals("Showdown • Pot 16 • You lost 8", summary.detail)
assertEquals(7, summary.handNumber)
}
@Test
fun `hero win and uncontested pot are explicit`() {
val summary = handSummaryFor(
handNumber = 3,
result = result(
net = intArrayOf(5, -2, -3),
winners = listOf(0),
showdown = false,
pot = 6,
),
seatNames = listOf("You", "Ada", "Bruno"),
)
assertEquals("You win", summary.title)
assertEquals("Uncontested • Pot 6 • You won 5", summary.detail)
}
@Test
fun `several side-pot winners are not described as one split pot`() {
val summary = handSummaryFor(
handNumber = 9,
result = result(
net = intArrayOf(0, 12, -12),
winners = listOf(0, 1),
pot = 40,
),
seatNames = listOf("You", "Ada", "Bruno"),
)
assertEquals("You & Ada win", summary.title)
assertTrue(summary.detail.endsWith("You broke even"))
}
}
class VisibleHandSummaryTest {
private val summary = HandSummary(4, listOf("Ada"), 20, -4, true)
private fun snapshot(
handNumber: Int = 4,
phase: TableSnapshot.Phase = TableSnapshot.Phase.SHOWDOWN,
) = TableSnapshot(
handNumber = handNumber,
street = Street.RIVER,
phase = phase,
board = emptyList(),
pot = 20,
currentBet = 0,
minRaiseSize = 2,
button = 0,
seats = emptyList(),
toAct = null,
toActToken = null,
lastAction = null,
)
@Test
fun `summary is visible only on its own terminal frame`() {
assertEquals(
summary,
UiState(snapshot = snapshot(), handSummary = summary).visibleHandSummary(),
)
assertNull(
UiState(snapshot = snapshot(handNumber = 5), handSummary = summary).visibleHandSummary(),
)
assertNull(
UiState(
snapshot = snapshot(phase = TableSnapshot.Phase.BETTING),
handSummary = summary,
).visibleHandSummary(),
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class NextHandGateTest {
@Test
fun `a duplicate old tap cannot release a future hand`() = runTest {
val gate = NextHandGate()
gate.request(4)
gate.awaitRequest(4)
val next = async { gate.awaitRequest(5) }
runCurrent()
assertFalse(next.isCompleted)
gate.request(4)
runCurrent()
assertFalse(next.isCompleted)
gate.request(5)
runCurrent()
assertTrue(next.isCompleted)
}
}