From 679b25e2c7528fe761874882a1ea6c49637948f8 Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 25 Jul 2026 16:04:30 -0400 Subject: [PATCH] Publish the all-in runout street by street MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During an all-in runout, dealRemainingBoard() dealt every remaining card without touching currentStreet, so a pre-flop all-in produced a terminal snapshot labelled PREFLOP carrying a five-card board. Setting currentStreet = RIVER would fix the label but leave a second problem: the board jumped from empty to complete in a single snapshot, so the UI could not animate the runout — the moment a poker table most needs to. Instead dealRemainingBoard() is now suspend and publishes each street as it lands, which keeps currentStreet honest as a consequence rather than as a special case. Each runout street also sweeps its betting into the pot via prepareRound(), matching the normal street transition. Verified by reverting: the board went 0 -> 5 with the terminal snapshot labelled PREFLOP, and three of the four new tests failed. Tests: 48 -> 52, green on jvmTest and testAndroidHostTest. Co-Authored-By: Claude Opus 5 (1M context) --- .../kotlin/com/jsjdesigns/poker/game/Table.kt | 14 +++- .../poker/game/SnapshotAndHumanAgentTest.kt | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) 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 a9386e6..5a690e0 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -305,7 +305,15 @@ class Table( board.add(deck.deal()) } - private fun dealRemainingBoard(from: Street) { + /** + * Runs the board out when nobody can act any more. + * + * Publishes each street as it lands rather than dealing all five cards + * silently: an all-in runout is the moment a poker UI most needs to animate, + * and it also keeps [currentStreet] honest — otherwise a pre-flop all-in ends + * on a snapshot labelled PREFLOP that carries a five-card board. + */ + private suspend fun dealRemainingBoard(from: Street) { var s = from while (s != Street.RIVER) { s = when (s) { @@ -314,6 +322,10 @@ class Table( Street.TURN -> { dealRiver(); Street.RIVER } Street.RIVER -> Street.RIVER } + currentStreet = s + // Sweep the completed betting into the pot before showing the card. + prepareRound(s) + emit(TableSnapshot.Phase.STREET_COMPLETE) } } 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 a819d44..aa9c08a 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt @@ -277,3 +277,80 @@ class HumanOfferAndStreetStateTest { assertTrue(streetStarts.all { it.pot > 0 }, "the pot carries forward") } } + +/** Shoves everything in at the first opportunity. */ +private class Shover : PlayerAgent { + override suspend fun act(ctx: DecisionContext): Action = when { + ctx.canRaise -> Action(ActionType.RAISE, ctx.maxRaiseTo) + ctx.toCall > 0 -> Action(ActionType.CALL, ctx.toCall) + else -> Action(ActionType.CHECK) + } +} + +class AllInRunoutTest { + + private suspend fun runPreflopAllIn(): List { + val seen = mutableListOf() + val seats = listOf(Seat(0, "A", 100, Shover()), Seat(1, "B", 100, Shover())) + Table( + seats, 5, 10, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"), + observer = { seen += it }, + ).playHand() + return seen + } + + @Test + fun `a preflop all-in never reports a full board while still labelled preflop`() = runTest { + val seen = runPreflopAllIn() + for (snap in seen) { + if (snap.board.size == 5) { + assertEquals( + Street.RIVER, snap.street, + "a five-card board must be labelled RIVER, was ${snap.street}", + ) + } + if (snap.street == Street.PREFLOP) { + assertEquals(0, snap.board.size, "PREFLOP must never carry board cards") + } + } + } + + @Test + fun `the runout is published street by street so the UI can animate it`() = runTest { + val seen = runPreflopAllIn() + val boardSizes = seen.map { it.board.size }.distinct() + assertTrue( + boardSizes.containsAll(listOf(0, 3, 4, 5)), + "expected the board to appear 0 -> 3 -> 4 -> 5, saw $boardSizes", + ) + + val streets = seen.filter { it.phase == TableSnapshot.Phase.STREET_COMPLETE }.map { it.street } + assertEquals( + listOf(Street.FLOP, Street.TURN, Street.RIVER), streets, + "each street of the runout should be published in order", + ) + } + + @Test + fun `the terminal snapshot of an all-in is a river showdown`() = runTest { + val seen = runPreflopAllIn() + val last = seen.last() + assertEquals(Street.RIVER, last.street) + assertEquals(5, last.board.size) + assertEquals(TableSnapshot.Phase.SHOWDOWN, last.phase) + assertTrue(last.pot > 0, "the terminal snapshot must still show the contested pot") + } + + @Test + fun `runout snapshots do not carry stale betting`() = runTest { + val seen = runPreflopAllIn() + for (snap in seen.filter { it.phase == TableSnapshot.Phase.STREET_COMPLETE }) { + assertEquals(0, snap.currentBet, "no bet is outstanding during a runout") + assertTrue( + snap.seats.all { it.committedThisRound == 0 }, + "chips should already be swept into the pot", + ) + } + } +}