Pause between hands and show results
This commit is contained in:
@@ -108,9 +108,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
|||||||
- Anything consuming `DecisionContext.history` across hands must key off
|
- Anything consuming `DecisionContext.history` across hands must key off
|
||||||
`handNumber`. History is cleared each hand, so a size comparison silently drops
|
`handNumber`. History is cleared each hand, so a size comparison silently drops
|
||||||
events.
|
events.
|
||||||
- Known-imperfect: win-rate magnitudes are still ~10x realistic, and several
|
- Android is playable in Compose. Engine snapshots are paced through a
|
||||||
profiles are looser than their labels (the Rock plays ~38% VPIP, should be
|
rendezvous channel, human decisions are matched by engine-owned tokens, and a
|
||||||
~12%). Tuning is the open work.
|
completed hand stays on screen until the player explicitly starts the next one.
|
||||||
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
|
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
|
||||||
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
|
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
|
||||||
- Not built yet: LLM persona layer, opt-in coach, Compose UI.
|
- Not built yet: LLM persona layer and opt-in coach.
|
||||||
|
|||||||
@@ -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(
|
data class UiState(
|
||||||
val snapshot: TableSnapshot? = null,
|
val snapshot: TableSnapshot? = null,
|
||||||
val handsPlayed: Int = 0,
|
val handsPlayed: Int = 0,
|
||||||
|
val handSummary: HandSummary? = null,
|
||||||
|
val nextHandRequested: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +51,18 @@ data class UiState(
|
|||||||
// is ambiguous and would briefly pair a new offer with a stale board.
|
// is ambiguous and would briefly pair a new offer with a stale board.
|
||||||
return offer.takeIf { snap.toActToken == it.token }
|
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() {
|
class PokerViewModel : ViewModel() {
|
||||||
|
|
||||||
private val human = HumanAgent()
|
private val human = HumanAgent()
|
||||||
|
private val nextHandGate = NextHandGate()
|
||||||
// RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of
|
// 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
|
// 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
|
// offered could belong to different moments — even different hands. With no
|
||||||
@@ -110,7 +125,17 @@ class PokerViewModel : ViewModel() {
|
|||||||
|
|
||||||
private suspend fun consumeFrames() {
|
private suspend fun consumeFrames() {
|
||||||
for (frame in frames) {
|
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))
|
delay(pacingMillis(frame))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,11 +160,41 @@ class PokerViewModel : ViewModel() {
|
|||||||
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
|
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
|
||||||
table.advanceButton()
|
table.advanceButton()
|
||||||
runCatching { table.playHand() }
|
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
|
.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
|
* Submits against the token the button was rendered from, so a stale or
|
||||||
* doubled tap is dropped rather than applied to whatever comes next.
|
* 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.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -48,6 +49,7 @@ fun TableScreen(vm: PokerViewModel) {
|
|||||||
val snap = state.snapshot
|
val snap = state.snapshot
|
||||||
// Only show an action bar that belongs to the table currently on screen.
|
// Only show an action bar that belongs to the table currently on screen.
|
||||||
val offer = state.liveOffer(rawOffer)
|
val offer = state.liveOffer(rawOffer)
|
||||||
|
val handSummary = state.visibleHandSummary()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -122,10 +124,13 @@ fun TableScreen(vm: PokerViewModel) {
|
|||||||
|
|
||||||
ActionBar(
|
ActionBar(
|
||||||
offer = offer,
|
offer = offer,
|
||||||
|
handSummary = handSummary,
|
||||||
|
nextHandRequested = state.nextHandRequested,
|
||||||
heroFolded = hero?.folded == true,
|
heroFolded = hero?.folded == true,
|
||||||
onFold = vm::fold,
|
onFold = vm::fold,
|
||||||
onCheckCall = vm::checkOrCall,
|
onCheckCall = vm::checkOrCall,
|
||||||
onRaise = vm::raiseTo,
|
onRaise = vm::raiseTo,
|
||||||
|
onNextHand = vm::nextHand,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,18 +222,29 @@ private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ActionBar(
|
private fun ActionBar(
|
||||||
offer: com.jsjdesigns.poker.game.DecisionOffer?,
|
offer: com.jsjdesigns.poker.game.DecisionOffer?,
|
||||||
|
handSummary: HandSummary?,
|
||||||
|
nextHandRequested: Boolean,
|
||||||
heroFolded: Boolean,
|
heroFolded: Boolean,
|
||||||
onFold: (Long) -> Unit,
|
onFold: (Long) -> Unit,
|
||||||
onCheckCall: (Long) -> Unit,
|
onCheckCall: (Long) -> Unit,
|
||||||
onRaise: (Long, Int) -> Unit,
|
onRaise: (Long, Int) -> Unit,
|
||||||
|
onNextHand: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (offer == null) {
|
if (offer == null) {
|
||||||
|
if (handSummary != null) {
|
||||||
|
HandResultBar(
|
||||||
|
summary = handSummary,
|
||||||
|
nextHandRequested = nextHandRequested,
|
||||||
|
onNextHand = onNextHand,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
if (heroFolded) "You folded — sitting out this hand" else "Waiting…",
|
if (heroFolded) "You folded — sitting out this hand" else "Waiting…",
|
||||||
color = Color.White.copy(alpha = 0.4f),
|
color = Color.White.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +260,11 @@ private fun ActionBar(
|
|||||||
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(),
|
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(),
|
||||||
onValueChange = { raiseTo = it.toInt() },
|
onValueChange = { raiseTo = it.toInt() },
|
||||||
valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(),
|
valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(),
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = Color(0xFFE3C179),
|
||||||
|
activeTrackColor = Color(0xFFE3C179),
|
||||||
|
inactiveTrackColor = Color.White.copy(alpha = 0.22f),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@@ -254,14 +275,20 @@ private fun ActionBar(
|
|||||||
Button(
|
Button(
|
||||||
onClick = { onFold(offer.token) },
|
onClick = { onFold(offer.token) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)),
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF8F1218),
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
) { Text("Fold") }
|
) { Text("Fold") }
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { onCheckCall(offer.token) },
|
onClick = { onCheckCall(offer.token) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF2C6E49),
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
) { Text(buttons.checkOrCallLabel) }
|
) { Text(buttons.checkOrCallLabel) }
|
||||||
|
|
||||||
if (buttons.showRaise) {
|
if (buttons.showRaise) {
|
||||||
@@ -273,13 +300,59 @@ private fun ActionBar(
|
|||||||
Button(
|
Button(
|
||||||
onClick = { onRaise(offer.token, amount) },
|
onClick = { onRaise(offer.token, amount) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF14357A),
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
) { Text(raiseLabel(buttons, amount)) }
|
) { 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
|
@Composable
|
||||||
private fun CardImage(index: Int?, modifier: Modifier) {
|
private fun CardImage(index: Int?, modifier: Modifier) {
|
||||||
Image(
|
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