Pause between hands and show results
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user