diff --git a/.gitignore b/.gitignore index 0be2ad1..6218225 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ captures/ # Logs / temp *.log *.hprof +gradle/gradle-daemon-jvm.properties diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index 6aa84ec..bd42d38 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -8,6 +8,7 @@ import com.jsjdesigns.poker.bot.PlayStyle import com.jsjdesigns.poker.bot.SkillLevel import com.jsjdesigns.poker.game.Action import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.HumanAgent import com.jsjdesigns.poker.game.Seat import com.jsjdesigns.poker.game.Table @@ -18,6 +19,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.random.Random @@ -30,7 +32,25 @@ data class UiState( val snapshot: TableSnapshot? = null, val handsPlayed: Int = 0, 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. @@ -47,7 +67,11 @@ data class UiState( class PokerViewModel : ViewModel() { private val human = HumanAgent() - private val frames = Channel(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(capacity = Channel.RENDEZVOUS) private val _state = MutableStateFlow(UiState()) val state: StateFlow = _state.asStateFlow() @@ -87,7 +111,7 @@ class PokerViewModel : ViewModel() { private suspend fun consumeFrames() { 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)) } } @@ -112,27 +136,33 @@ class PokerViewModel : ViewModel() { for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK table.advanceButton() 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 } } - 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 - 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() { - // Release a hand parked on human input so the coroutine can finish. - viewModelScope.launch { human.cancel() } + // No explicit human.cancel() here: viewModelScope is already cancelled by + // 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() super.onCleared() } diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 2a83d19..16cf227 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -44,8 +44,10 @@ private val Felt = Color(0xFF0B3D26) @Composable fun TableScreen(vm: PokerViewModel) { val state by vm.state.collectAsStateWithLifecycle() - val offer by vm.offer.collectAsStateWithLifecycle() + val rawOffer by vm.offer.collectAsStateWithLifecycle() val snap = state.snapshot + // Only show an action bar that belongs to the table currently on screen. + val offer = state.liveOffer(rawOffer) Column( modifier = Modifier @@ -117,6 +119,7 @@ fun TableScreen(vm: PokerViewModel) { ActionBar( offer = offer, + heroFolded = hero?.folded == true, onFold = vm::fold, onCheckCall = vm::checkOrCall, onRaise = vm::raiseTo, @@ -172,12 +175,17 @@ private fun OpponentSeat(seat: SeatSnapshot, isTurn: Boolean) { @Composable private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) { + val folded = seat?.folded == true Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, 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 -> CardImage(seat?.hole?.getOrNull(i), Modifier.size(64.dp, 96.dp)) } @@ -185,9 +193,16 @@ private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) { Column { Text( (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, ) + if (folded) { + Text("FOLDED", color = Color(0xFFC9545B), fontSize = 12.sp, fontWeight = FontWeight.Bold) + } Text("${seat?.stack ?: 0}", color = Color.White.copy(alpha = 0.75f)) if ((seat?.committedThisRound ?: 0) > 0) { Text("bet ${seat?.committedThisRound}", color = Color(0xFFE3C179), fontSize = 12.sp) @@ -199,13 +214,17 @@ private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) { @Composable private fun ActionBar( offer: com.jsjdesigns.poker.game.DecisionOffer?, - onFold: () -> Unit, - onCheckCall: () -> Unit, - onRaise: (Int) -> Unit, + heroFolded: Boolean, + onFold: (Long) -> Unit, + onCheckCall: (Long) -> Unit, + onRaise: (Long, Int) -> Unit, ) { if (offer == null) { 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 } @@ -227,21 +246,25 @@ private fun ActionBar( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Button( - onClick = onFold, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)), - ) { Text("Fold") } + // No Fold button when checking is free: folding a free hand is never + // correct, and offering it invites the player to muck by accident. + if (!offer.canCheck) { + Button( + onClick = { onFold(offer.token) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)), + ) { Text("Fold") } + } Button( - onClick = onCheckCall, + onClick = { onCheckCall(offer.token) }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)), ) { Text(if (offer.canCheck) "Check" else "Call ${offer.toCall}") } if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) { Button( - onClick = { onRaise(raiseTo) }, + onClick = { onRaise(offer.token, raiseTo) }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)), ) { Text(if (raiseTo >= offer.maxRaiseTo) "All in" else "Raise") } diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt index 63d9b83..a98bc47 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt @@ -9,6 +9,14 @@ package com.jsjdesigns.poker.game * is needed to render an action bar. */ 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 street: Street, val seat: Int, @@ -26,7 +34,8 @@ data class DecisionOffer( val callAmount: Int get() = toCall } -fun DecisionContext.toOffer(): DecisionOffer = DecisionOffer( +fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer( + token = token, handNumber = handNumber, street = street, seat = seat.index, diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt index 14a73bd..fb06098 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt @@ -23,6 +23,8 @@ class HumanAgent : PlayerAgent { private val lock = Mutex() private var pending: CompletableDeferred? = null + private var pendingToken = 0L + private var nextToken = 1L private val _offer = MutableStateFlow(null) @@ -33,13 +35,16 @@ class HumanAgent : PlayerAgent { override suspend fun act(ctx: DecisionContext): Action { val deferred = CompletableDeferred() + val token: Long lock.withLock { check(pending == null) { "already awaiting a decision for this agent" } pending = deferred + token = nextToken++ + pendingToken = token } // Published after the deferred is installed, so a UI that reacts instantly // to the offer always finds something able to receive its submission. - _offer.value = ctx.toOffer() + _offer.value = ctx.toOffer(token) return try { deferred.await() @@ -52,11 +57,16 @@ class HumanAgent : PlayerAgent { } /** - * Supplies the player's choice. Returns false when nothing was waiting, which - * makes a double-tap or a stale click harmless rather than a crash. + * Supplies the player's choice for the decision identified by [token]. + * + * 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 + if (token != pendingToken) return@withLock false deferred.complete(action) } diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt index 5a690e0..2c4e9a7 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -460,7 +460,11 @@ class Table( /** Clamps whatever an agent returns into something legal. */ private fun sanitise(seat: Seat, toCall: Int, action: Action): Action { 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.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action ActionType.BET, ActionType.RAISE -> { diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt new file mode 100644 index 0000000..b20ded2 --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt @@ -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() + + 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") + } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt index aa9c08a..2517530 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt @@ -142,7 +142,7 @@ class SnapshotAndHumanAgentTest { while (!human.isAwaitingInput) yield() 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) assertFalse(human.isAwaitingInput, "the wait must clear once answered") @@ -151,7 +151,7 @@ class SnapshotAndHumanAgentTest { @Test fun `submitting when nothing is pending is harmless`() = runTest { 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 @@ -171,7 +171,7 @@ class SnapshotAndHumanAgentTest { // The agent is reusable afterwards. val next = async { human.act(context(seat)) } 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) } @@ -189,7 +189,7 @@ class SnapshotAndHumanAgentTest { val hand = async { table.playHand() } // Fold at the first opportunity. while (!human.isAwaitingInput) yield() - human.submit(Action(ActionType.FOLD)) + human.submit(human.offer.value!!.token, Action(ActionType.FOLD)) val result = hand.await() 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(listOf(0, 5), offer.hole) - human.submit(Action(ActionType.FOLD)) + human.submit(human.offer.value!!.token, Action(ActionType.FOLD)) decision.await() } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotDeliveryTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotDeliveryTest.kt index 9bda8aa..f47fcc7 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotDeliveryTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotDeliveryTest.kt @@ -2,6 +2,7 @@ package com.jsjdesigns.poker.game import com.jsjdesigns.poker.core.StackedDeck import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch 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 - 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() - // Capacity 1 forces the engine to wait on nearly every emission. - val channel = Channel(capacity = 1) + var sent = 0 + var maxLead = 0 + val channel = Channel(capacity = Channel.RENDEZVOUS) 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())) - table({ channel.send(it) }, seats).playHand() + table( + observer = { + sent++ + channel.send(it) + maxLead = maxOf(maxLead, sent - received.size) + }, + seats = seats, + ).playHand() channel.close() consumer.join() assertEquals( 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() + var sent = 0 + var maxLead = 0 + val channel = Channel(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", ) } }