Fix the fold experience: honest actions, matched state, action identity
Reported as "folding looks broken". It was five separate defects.
1. Fold silently became Check. sanitise() rewrote FOLD to CHECK whenever
checking was free, so a UI showing a Fold button folded nothing and the
player kept being asked to act. Folding is legal at any turn — it simply
mucks — so FOLD is now honoured literally. The engine must never substitute a
different action than the caller asked for. The reverse rewrite (an illegal
CHECK facing a bet becoming FOLD) is legitimate and stays.
Safe by construction: no bot emits FOLD when it can check, and the 20k-hand
simulation reproduces byte-identical numbers (289.12 / 113.19 / -195.64).
2. Snapshot and offer could describe different moments. The 32-deep frame
channel let the engine race far ahead of the animation, so the action on
offer could belong to a later street, or another hand. The channel is now
RENDEZVOUS, capping the engine at one frame ahead, and UiState.liveOffer()
only surfaces an offer whose hand and street match the table on screen.
3. Stale and double taps could act on a later decision. DecisionOffer now
carries a token; submit() requires it and rejects anything stale, so a second
tap is dropped rather than applied to whatever comes next.
4. A real fold was invisible. The hero kept normal cards and no folded state, so
a correctly processed fold looked like a bug. Cards now dim, FOLDED shows in
red, and the action bar explains the player is sitting out.
5. Non-atomic UiState updates from two coroutines now use update {}.
Also: Fold is hidden when checking is free (folding a free hand is never
correct, and offering it invites an accidental muck), and onCleared no longer
calls human.cancel() — viewModelScope is already cancelled by then so the launch
never ran; scope cancellation already propagates into act()'s finally.
The delivery tests were weak as charged: no slow consumer, and not the app's
capacity. Replaced with a genuinely slow consumer measuring how far the engine
runs ahead — asserting <= 1 on RENDEZVOUS, and > 1 on a 32-deep buffer to
document why the buffer was removed.
Verified on the emulator (physical device untouched): folded facing a bet, hero
showed FOLDED, was never asked again that hand, and play advanced to hand 2.
Tests: 55 -> 62, green on jvmTest and testAndroidHostTest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,3 +27,4 @@ captures/
|
|||||||
# Logs / temp
|
# Logs / temp
|
||||||
*.log
|
*.log
|
||||||
*.hprof
|
*.hprof
|
||||||
|
gradle/gradle-daemon-jvm.properties
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.jsjdesigns.poker.bot.PlayStyle
|
|||||||
import com.jsjdesigns.poker.bot.SkillLevel
|
import com.jsjdesigns.poker.bot.SkillLevel
|
||||||
import com.jsjdesigns.poker.game.Action
|
import com.jsjdesigns.poker.game.Action
|
||||||
import com.jsjdesigns.poker.game.ActionType
|
import com.jsjdesigns.poker.game.ActionType
|
||||||
|
import com.jsjdesigns.poker.game.DecisionOffer
|
||||||
import com.jsjdesigns.poker.game.HumanAgent
|
import com.jsjdesigns.poker.game.HumanAgent
|
||||||
import com.jsjdesigns.poker.game.Seat
|
import com.jsjdesigns.poker.game.Seat
|
||||||
import com.jsjdesigns.poker.game.Table
|
import com.jsjdesigns.poker.game.Table
|
||||||
@@ -18,6 +19,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@@ -30,7 +32,25 @@ data class UiState(
|
|||||||
val snapshot: TableSnapshot? = null,
|
val snapshot: TableSnapshot? = null,
|
||||||
val handsPlayed: Int = 0,
|
val handsPlayed: Int = 0,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* The decision to show, or null.
|
||||||
|
*
|
||||||
|
* An offer is only live when the table on screen is the one it belongs to.
|
||||||
|
* The engine can be a frame ahead of the animation, so a raw offer could
|
||||||
|
* otherwise be rendered against a stale board — or worse, against a different
|
||||||
|
* hand entirely.
|
||||||
|
*/
|
||||||
|
fun liveOffer(offer: DecisionOffer?): DecisionOffer? {
|
||||||
|
val snap = snapshot ?: return null
|
||||||
|
if (offer == null) return null
|
||||||
|
return offer.takeIf {
|
||||||
|
it.handNumber == snap.handNumber &&
|
||||||
|
snap.toAct == HERO_SEAT &&
|
||||||
|
it.street == snap.street
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives a continuous cash game and publishes it to Compose.
|
* Drives a continuous cash game and publishes it to Compose.
|
||||||
@@ -47,7 +67,11 @@ data class UiState(
|
|||||||
class PokerViewModel : ViewModel() {
|
class PokerViewModel : ViewModel() {
|
||||||
|
|
||||||
private val human = HumanAgent()
|
private val human = HumanAgent()
|
||||||
private val frames = Channel<TableSnapshot>(capacity = 32)
|
// 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
|
||||||
|
// buffer the engine is at most one frame ahead of what the player can see.
|
||||||
|
private val frames = Channel<TableSnapshot>(capacity = Channel.RENDEZVOUS)
|
||||||
|
|
||||||
private val _state = MutableStateFlow(UiState())
|
private val _state = MutableStateFlow(UiState())
|
||||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
@@ -87,7 +111,7 @@ class PokerViewModel : ViewModel() {
|
|||||||
|
|
||||||
private suspend fun consumeFrames() {
|
private suspend fun consumeFrames() {
|
||||||
for (frame in frames) {
|
for (frame in frames) {
|
||||||
_state.value = _state.value.copy(snapshot = frame.maskedFor(HERO_SEAT))
|
_state.update { it.copy(snapshot = frame.maskedFor(HERO_SEAT)) }
|
||||||
delay(pacingMillis(frame))
|
delay(pacingMillis(frame))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,27 +136,33 @@ 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.value = _state.value.copy(handsPlayed = _state.value.handsPlayed + 1) }
|
.onSuccess { _state.update { s -> s.copy(handsPlayed = s.handsPlayed + 1) } }
|
||||||
.onFailure { return } // scope cancelled: the screen went away
|
.onFailure { return } // scope cancelled: the screen went away
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submit(action: Action) {
|
/**
|
||||||
viewModelScope.launch { human.submit(action) }
|
* Submits against the token the button was rendered from, so a stale or
|
||||||
|
* doubled tap is dropped rather than applied to whatever comes next.
|
||||||
|
*/
|
||||||
|
fun submit(token: Long, action: Action) {
|
||||||
|
viewModelScope.launch { human.submit(token, action) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fold() = submit(Action(ActionType.FOLD))
|
fun fold(token: Long) = submit(token, Action(ActionType.FOLD))
|
||||||
|
|
||||||
fun checkOrCall() {
|
fun checkOrCall(token: Long) {
|
||||||
val o = offer.value ?: return
|
val o = offer.value ?: return
|
||||||
submit(if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.toCall))
|
if (o.token != token) return
|
||||||
|
submit(token, if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.toCall))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun raiseTo(amount: Int) = submit(Action(ActionType.RAISE, amount))
|
fun raiseTo(token: Long, amount: Int) = submit(token, Action(ActionType.RAISE, amount))
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
// Release a hand parked on human input so the coroutine can finish.
|
// No explicit human.cancel() here: viewModelScope is already cancelled by
|
||||||
viewModelScope.launch { human.cancel() }
|
// this point, so a launch would never run. Cancelling the scope propagates
|
||||||
|
// into HumanAgent.act()'s await, whose finally clears the pending state.
|
||||||
frames.close()
|
frames.close()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ private val Felt = Color(0xFF0B3D26)
|
|||||||
@Composable
|
@Composable
|
||||||
fun TableScreen(vm: PokerViewModel) {
|
fun TableScreen(vm: PokerViewModel) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val offer by vm.offer.collectAsStateWithLifecycle()
|
val rawOffer by vm.offer.collectAsStateWithLifecycle()
|
||||||
val snap = state.snapshot
|
val snap = state.snapshot
|
||||||
|
// Only show an action bar that belongs to the table currently on screen.
|
||||||
|
val offer = state.liveOffer(rawOffer)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -117,6 +119,7 @@ fun TableScreen(vm: PokerViewModel) {
|
|||||||
|
|
||||||
ActionBar(
|
ActionBar(
|
||||||
offer = offer,
|
offer = offer,
|
||||||
|
heroFolded = hero?.folded == true,
|
||||||
onFold = vm::fold,
|
onFold = vm::fold,
|
||||||
onCheckCall = vm::checkOrCall,
|
onCheckCall = vm::checkOrCall,
|
||||||
onRaise = vm::raiseTo,
|
onRaise = vm::raiseTo,
|
||||||
@@ -172,12 +175,17 @@ private fun OpponentSeat(seat: SeatSnapshot, isTurn: Boolean) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
|
private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
|
||||||
|
val folded = seat?.folded == true
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
// A processed fold has to *look* folded, or a correct fold reads as a bug.
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.alpha(if (folded) 0.25f else 1f),
|
||||||
|
) {
|
||||||
repeat(2) { i ->
|
repeat(2) { i ->
|
||||||
CardImage(seat?.hole?.getOrNull(i), Modifier.size(64.dp, 96.dp))
|
CardImage(seat?.hole?.getOrNull(i), Modifier.size(64.dp, 96.dp))
|
||||||
}
|
}
|
||||||
@@ -185,9 +193,16 @@ private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
|
|||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
(seat?.name ?: "You") + if (seat?.isButton == true) " ⏺" else "",
|
(seat?.name ?: "You") + if (seat?.isButton == true) " ⏺" else "",
|
||||||
color = if (isTurn) Color(0xFFE3C179) else Color.White,
|
color = when {
|
||||||
|
folded -> Color.White.copy(alpha = 0.4f)
|
||||||
|
isTurn -> Color(0xFFE3C179)
|
||||||
|
else -> Color.White
|
||||||
|
},
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
if (folded) {
|
||||||
|
Text("FOLDED", color = Color(0xFFC9545B), fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
Text("${seat?.stack ?: 0}", color = Color.White.copy(alpha = 0.75f))
|
Text("${seat?.stack ?: 0}", color = Color.White.copy(alpha = 0.75f))
|
||||||
if ((seat?.committedThisRound ?: 0) > 0) {
|
if ((seat?.committedThisRound ?: 0) > 0) {
|
||||||
Text("bet ${seat?.committedThisRound}", color = Color(0xFFE3C179), fontSize = 12.sp)
|
Text("bet ${seat?.committedThisRound}", color = Color(0xFFE3C179), fontSize = 12.sp)
|
||||||
@@ -199,13 +214,17 @@ 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?,
|
||||||
onFold: () -> Unit,
|
heroFolded: Boolean,
|
||||||
onCheckCall: () -> Unit,
|
onFold: (Long) -> Unit,
|
||||||
onRaise: (Int) -> Unit,
|
onCheckCall: (Long) -> Unit,
|
||||||
|
onRaise: (Long, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (offer == null) {
|
if (offer == null) {
|
||||||
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
|
||||||
Text("Waiting…", color = Color.White.copy(alpha = 0.4f))
|
Text(
|
||||||
|
if (heroFolded) "You folded — sitting out this hand" else "Waiting…",
|
||||||
|
color = Color.White.copy(alpha = 0.4f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -227,21 +246,25 @@ private fun ActionBar(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Button(
|
// No Fold button when checking is free: folding a free hand is never
|
||||||
onClick = onFold,
|
// correct, and offering it invites the player to muck by accident.
|
||||||
modifier = Modifier.weight(1f),
|
if (!offer.canCheck) {
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)),
|
Button(
|
||||||
) { Text("Fold") }
|
onClick = { onFold(offer.token) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)),
|
||||||
|
) { Text("Fold") }
|
||||||
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onCheckCall,
|
onClick = { onCheckCall(offer.token) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
|
||||||
) { Text(if (offer.canCheck) "Check" else "Call ${offer.toCall}") }
|
) { Text(if (offer.canCheck) "Check" else "Call ${offer.toCall}") }
|
||||||
|
|
||||||
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
|
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onRaise(raiseTo) },
|
onClick = { onRaise(offer.token, raiseTo) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
|
||||||
) { Text(if (raiseTo >= offer.maxRaiseTo) "All in" else "Raise") }
|
) { Text(if (raiseTo >= offer.maxRaiseTo) "All in" else "Raise") }
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ package com.jsjdesigns.poker.game
|
|||||||
* is needed to render an action bar.
|
* is needed to render an action bar.
|
||||||
*/
|
*/
|
||||||
data class DecisionOffer(
|
data class DecisionOffer(
|
||||||
|
/**
|
||||||
|
* Identifies this specific decision.
|
||||||
|
*
|
||||||
|
* Submissions carry it back so a stale or double tap cannot be applied to a
|
||||||
|
* later decision — a second tap landing after the turn moved on would
|
||||||
|
* otherwise act on the next street, or even the next hand.
|
||||||
|
*/
|
||||||
|
val token: Long,
|
||||||
val handNumber: Int,
|
val handNumber: Int,
|
||||||
val street: Street,
|
val street: Street,
|
||||||
val seat: Int,
|
val seat: Int,
|
||||||
@@ -26,7 +34,8 @@ data class DecisionOffer(
|
|||||||
val callAmount: Int get() = toCall
|
val callAmount: Int get() = toCall
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DecisionContext.toOffer(): DecisionOffer = DecisionOffer(
|
fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer(
|
||||||
|
token = token,
|
||||||
handNumber = handNumber,
|
handNumber = handNumber,
|
||||||
street = street,
|
street = street,
|
||||||
seat = seat.index,
|
seat = seat.index,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class HumanAgent : PlayerAgent {
|
|||||||
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private var pending: CompletableDeferred<Action>? = null
|
private var pending: CompletableDeferred<Action>? = null
|
||||||
|
private var pendingToken = 0L
|
||||||
|
private var nextToken = 1L
|
||||||
|
|
||||||
private val _offer = MutableStateFlow<DecisionOffer?>(null)
|
private val _offer = MutableStateFlow<DecisionOffer?>(null)
|
||||||
|
|
||||||
@@ -33,13 +35,16 @@ class HumanAgent : PlayerAgent {
|
|||||||
|
|
||||||
override suspend fun act(ctx: DecisionContext): Action {
|
override suspend fun act(ctx: DecisionContext): Action {
|
||||||
val deferred = CompletableDeferred<Action>()
|
val deferred = CompletableDeferred<Action>()
|
||||||
|
val token: Long
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
check(pending == null) { "already awaiting a decision for this agent" }
|
check(pending == null) { "already awaiting a decision for this agent" }
|
||||||
pending = deferred
|
pending = deferred
|
||||||
|
token = nextToken++
|
||||||
|
pendingToken = token
|
||||||
}
|
}
|
||||||
// Published after the deferred is installed, so a UI that reacts instantly
|
// Published after the deferred is installed, so a UI that reacts instantly
|
||||||
// to the offer always finds something able to receive its submission.
|
// to the offer always finds something able to receive its submission.
|
||||||
_offer.value = ctx.toOffer()
|
_offer.value = ctx.toOffer(token)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
deferred.await()
|
deferred.await()
|
||||||
@@ -52,11 +57,16 @@ class HumanAgent : PlayerAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supplies the player's choice. Returns false when nothing was waiting, which
|
* Supplies the player's choice for the decision identified by [token].
|
||||||
* makes a double-tap or a stale click harmless rather than a crash.
|
*
|
||||||
|
* Returns false when nothing is waiting or when [token] is stale. Requiring
|
||||||
|
* the token is what stops a double tap, or a tap that lands just after the
|
||||||
|
* turn moved on, from being applied to the *next* decision — which could be a
|
||||||
|
* different street or an entirely different hand.
|
||||||
*/
|
*/
|
||||||
suspend fun submit(action: Action): Boolean = lock.withLock {
|
suspend fun submit(token: Long, action: Action): Boolean = lock.withLock {
|
||||||
val deferred = pending ?: return@withLock false
|
val deferred = pending ?: return@withLock false
|
||||||
|
if (token != pendingToken) return@withLock false
|
||||||
deferred.complete(action)
|
deferred.complete(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -460,7 +460,11 @@ class Table(
|
|||||||
/** Clamps whatever an agent returns into something legal. */
|
/** Clamps whatever an agent returns into something legal. */
|
||||||
private fun sanitise(seat: Seat, toCall: Int, action: Action): Action {
|
private fun sanitise(seat: Seat, toCall: Int, action: Action): Action {
|
||||||
return when (action.type) {
|
return when (action.type) {
|
||||||
ActionType.FOLD -> if (toCall == 0) Action(ActionType.CHECK) else action
|
// Folding is legal at any turn, including when checking is free — it
|
||||||
|
// simply mucks. Rewriting it to CHECK was a lie to the caller: a UI
|
||||||
|
// showing a Fold button would fold, and the player would keep getting
|
||||||
|
// asked to act. Never silently substitute a different action.
|
||||||
|
ActionType.FOLD -> action
|
||||||
ActionType.CHECK -> if (toCall > 0) Action(ActionType.FOLD) else action
|
ActionType.CHECK -> if (toCall > 0) Action(ActionType.FOLD) else action
|
||||||
ActionType.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action
|
ActionType.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action
|
||||||
ActionType.BET, ActionType.RAISE -> {
|
ActionType.BET, ActionType.RAISE -> {
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.jsjdesigns.poker.game
|
||||||
|
|
||||||
|
import com.jsjdesigns.poker.core.StackedDeck
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private class AlwaysChecks : PlayerAgent {
|
||||||
|
override suspend fun act(ctx: DecisionContext): Action =
|
||||||
|
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folds the first time it acts, whatever the situation. */
|
||||||
|
private class FoldsOnce : PlayerAgent {
|
||||||
|
var timesAsked = 0
|
||||||
|
private set
|
||||||
|
private var folded = false
|
||||||
|
override suspend fun act(ctx: DecisionContext): Action {
|
||||||
|
timesAsked++
|
||||||
|
return if (!folded) {
|
||||||
|
folded = true
|
||||||
|
Action(ActionType.FOLD)
|
||||||
|
} else if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FoldAndTokenTest {
|
||||||
|
|
||||||
|
private fun context(seat: Seat, canCheck: Boolean = false) = DecisionContext(
|
||||||
|
street = Street.FLOP,
|
||||||
|
seat = seat,
|
||||||
|
board = IntArray(0),
|
||||||
|
pot = 20,
|
||||||
|
toCall = if (canCheck) 0 else 10,
|
||||||
|
minRaiseTo = 20,
|
||||||
|
maxRaiseTo = 500,
|
||||||
|
activeOpponents = 1,
|
||||||
|
seatsActingAfter = 0,
|
||||||
|
bigBlind = 10,
|
||||||
|
history = emptyList(),
|
||||||
|
handNumber = 1,
|
||||||
|
bettingReopened = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- fold is honoured, never silently substituted ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine used to rewrite FOLD to CHECK whenever checking was free. A UI
|
||||||
|
* showing a Fold button would then fold, and the player would be asked to act
|
||||||
|
* again on the next street — which is exactly what it looked like: a bug.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `folding when checking is free actually folds`() = runTest {
|
||||||
|
val folder = FoldsOnce()
|
||||||
|
val seats = listOf(Seat(0, "A", 500, folder), Seat(1, "B", 500, AlwaysChecks()))
|
||||||
|
val result = Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
assertTrue(seats[0].folded, "a fold must fold, even when checking was free")
|
||||||
|
assertEquals(listOf(1), result.winners, "the other player takes it uncontested")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a folded player is never asked to act again that hand`() = runTest {
|
||||||
|
val folder = FoldsOnce()
|
||||||
|
val seats = listOf(
|
||||||
|
Seat(0, "A", 500, folder),
|
||||||
|
Seat(1, "B", 500, AlwaysChecks()),
|
||||||
|
Seat(2, "C", 500, AlwaysChecks()),
|
||||||
|
)
|
||||||
|
Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
assertTrue(seats[0].folded)
|
||||||
|
assertEquals(
|
||||||
|
1, folder.timesAsked,
|
||||||
|
"a player who folded must not be offered another decision this hand",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checking into a bet is still downgraded to a fold`() = runTest {
|
||||||
|
// The reverse substitution is legitimate: CHECK is simply illegal there.
|
||||||
|
val seats = listOf(
|
||||||
|
Seat(0, "A", 500, PlayerAgent { Action(ActionType.RAISE, 100) }),
|
||||||
|
Seat(1, "B", 500, PlayerAgent { Action(ActionType.CHECK) }),
|
||||||
|
)
|
||||||
|
val result = Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
assertTrue(seats[1].folded, "an illegal check becomes a fold")
|
||||||
|
assertEquals(0, result.net.sum())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- decision tokens ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stale token is rejected`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
val seat = Seat(0, "You", 500, human)
|
||||||
|
|
||||||
|
val first = async { human.act(context(seat)) }
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
val staleToken = human.offer.value!!.token
|
||||||
|
assertTrue(human.submit(staleToken, Action(ActionType.FOLD)))
|
||||||
|
first.await()
|
||||||
|
|
||||||
|
// A second decision arrives with a new token.
|
||||||
|
val second = async { human.act(context(seat)) }
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
val freshToken = human.offer.value!!.token
|
||||||
|
assertNotEquals(staleToken, freshToken, "each decision gets its own token")
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
human.submit(staleToken, Action(ActionType.FOLD)),
|
||||||
|
"a tap left over from the previous decision must not be applied here",
|
||||||
|
)
|
||||||
|
assertTrue(human.submit(freshToken, Action(ActionType.CHECK)))
|
||||||
|
assertEquals(ActionType.CHECK, second.await().type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a double tap does not act twice`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
val seat = Seat(0, "You", 500, human)
|
||||||
|
|
||||||
|
val decision = async { human.act(context(seat)) }
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
val token = human.offer.value!!.token
|
||||||
|
|
||||||
|
assertTrue(human.submit(token, Action(ActionType.FOLD)), "first tap lands")
|
||||||
|
assertFalse(human.submit(token, Action(ActionType.CALL, 10)), "second tap is dropped")
|
||||||
|
assertEquals(ActionType.FOLD, decision.await().type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tokens keep advancing across decisions`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
val seat = Seat(0, "You", 500, human)
|
||||||
|
val seen = mutableListOf<Long>()
|
||||||
|
|
||||||
|
repeat(3) {
|
||||||
|
val d = async { human.act(context(seat)) }
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
val t = human.offer.value!!.token
|
||||||
|
seen += t
|
||||||
|
human.submit(t, Action(ActionType.CHECK))
|
||||||
|
d.await()
|
||||||
|
}
|
||||||
|
assertEquals(seen.distinct(), seen, "tokens must never repeat")
|
||||||
|
assertEquals(seen.sorted(), seen, "and should advance monotonically")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ class SnapshotAndHumanAgentTest {
|
|||||||
|
|
||||||
while (!human.isAwaitingInput) yield()
|
while (!human.isAwaitingInput) yield()
|
||||||
assertNotNull(human.offer.value, "the UI must be able to see what is on offer")
|
assertNotNull(human.offer.value, "the UI must be able to see what is on offer")
|
||||||
assertTrue(human.submit(Action(ActionType.CALL, 10)))
|
assertTrue(human.submit(human.offer.value!!.token, Action(ActionType.CALL, 10)))
|
||||||
|
|
||||||
assertEquals(ActionType.CALL, decision.await().type)
|
assertEquals(ActionType.CALL, decision.await().type)
|
||||||
assertFalse(human.isAwaitingInput, "the wait must clear once answered")
|
assertFalse(human.isAwaitingInput, "the wait must clear once answered")
|
||||||
@@ -151,7 +151,7 @@ class SnapshotAndHumanAgentTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `submitting when nothing is pending is harmless`() = runTest {
|
fun `submitting when nothing is pending is harmless`() = runTest {
|
||||||
val human = HumanAgent()
|
val human = HumanAgent()
|
||||||
assertFalse(human.submit(Action(ActionType.FOLD)), "a stale tap must not crash")
|
assertFalse(human.submit(1L, Action(ActionType.FOLD)), "a stale tap must not crash")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -171,7 +171,7 @@ class SnapshotAndHumanAgentTest {
|
|||||||
// The agent is reusable afterwards.
|
// The agent is reusable afterwards.
|
||||||
val next = async { human.act(context(seat)) }
|
val next = async { human.act(context(seat)) }
|
||||||
while (!human.isAwaitingInput) yield()
|
while (!human.isAwaitingInput) yield()
|
||||||
assertTrue(human.submit(Action(ActionType.FOLD)))
|
assertTrue(human.submit(human.offer.value!!.token, Action(ActionType.FOLD)))
|
||||||
assertEquals(ActionType.FOLD, next.await().type)
|
assertEquals(ActionType.FOLD, next.await().type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ class SnapshotAndHumanAgentTest {
|
|||||||
val hand = async { table.playHand() }
|
val hand = async { table.playHand() }
|
||||||
// Fold at the first opportunity.
|
// Fold at the first opportunity.
|
||||||
while (!human.isAwaitingInput) yield()
|
while (!human.isAwaitingInput) yield()
|
||||||
human.submit(Action(ActionType.FOLD))
|
human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
|
||||||
|
|
||||||
val result = hand.await()
|
val result = hand.await()
|
||||||
assertEquals(0, result.net.sum(), "chips conserved through a human-driven hand")
|
assertEquals(0, result.net.sum(), "chips conserved through a human-driven hand")
|
||||||
@@ -250,7 +250,7 @@ class HumanOfferAndStreetStateTest {
|
|||||||
assertEquals(500, offer.stack, "offer must not alias live seat state")
|
assertEquals(500, offer.stack, "offer must not alias live seat state")
|
||||||
assertEquals(listOf(0, 5), offer.hole)
|
assertEquals(listOf(0, 5), offer.hole)
|
||||||
|
|
||||||
human.submit(Action(ActionType.FOLD))
|
human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
|
||||||
decision.await()
|
decision.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.jsjdesigns.poker.game
|
|||||||
|
|
||||||
import com.jsjdesigns.poker.core.StackedDeck
|
import com.jsjdesigns.poker.core.StackedDeck
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -81,24 +82,79 @@ class SnapshotDeliveryTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A genuinely slow consumer, on a RENDEZVOUS channel — what the app uses.
|
||||||
|
* The engine must never be more than one frame ahead of what has been shown.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `backpressure lets a slow consumer keep up without dropping frames`() = runTest {
|
fun `a rendezvous channel keeps the engine within one frame of a slow consumer`() = runTest {
|
||||||
val received = mutableListOf<TableSnapshot>()
|
val received = mutableListOf<TableSnapshot>()
|
||||||
// Capacity 1 forces the engine to wait on nearly every emission.
|
var sent = 0
|
||||||
val channel = Channel<TableSnapshot>(capacity = 1)
|
var maxLead = 0
|
||||||
|
val channel = Channel<TableSnapshot>(capacity = Channel.RENDEZVOUS)
|
||||||
|
|
||||||
val consumer = launch {
|
val consumer = launch {
|
||||||
for (frame in channel) received += frame
|
for (frame in channel) {
|
||||||
|
received += frame
|
||||||
|
delay(50) // pretend to animate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
|
val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
|
||||||
table({ channel.send(it) }, seats).playHand()
|
table(
|
||||||
|
observer = {
|
||||||
|
sent++
|
||||||
|
channel.send(it)
|
||||||
|
maxLead = maxOf(maxLead, sent - received.size)
|
||||||
|
},
|
||||||
|
seats = seats,
|
||||||
|
).playHand()
|
||||||
channel.close()
|
channel.close()
|
||||||
consumer.join()
|
consumer.join()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(0, 3, 4, 5), received.map { it.board.size }.distinct().sorted(),
|
listOf(0, 3, 4, 5), received.map { it.board.size }.distinct().sorted(),
|
||||||
"a suspending observer means the engine cannot outrun the UI",
|
"no frame may be dropped no matter how slow the consumer",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
maxLead <= 1,
|
||||||
|
"engine ran $maxLead frames ahead of the display; rendezvous should cap it at 1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the buffer was removed: with depth, the engine races ahead and the
|
||||||
|
* action on offer can belong to a later moment than the table on screen.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a deep buffer lets the engine run far ahead of the display`() = runTest {
|
||||||
|
val received = mutableListOf<TableSnapshot>()
|
||||||
|
var sent = 0
|
||||||
|
var maxLead = 0
|
||||||
|
val channel = Channel<TableSnapshot>(capacity = 32)
|
||||||
|
|
||||||
|
val consumer = launch {
|
||||||
|
for (frame in channel) {
|
||||||
|
received += frame
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
|
||||||
|
table(
|
||||||
|
observer = {
|
||||||
|
sent++
|
||||||
|
channel.send(it)
|
||||||
|
maxLead = maxOf(maxLead, sent - received.size)
|
||||||
|
},
|
||||||
|
seats = seats,
|
||||||
|
).playHand()
|
||||||
|
channel.close()
|
||||||
|
consumer.join()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
maxLead > 1,
|
||||||
|
"a 32-deep buffer should let the engine outrun the display, saw lead of $maxLead",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user