Publish the all-in runout street by street

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) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 16:04:30 -04:00
parent eefcd5966c
commit 679b25e2c7
2 changed files with 90 additions and 1 deletions
@@ -305,7 +305,15 @@ class Table(
board.add(deck.deal()) 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 var s = from
while (s != Street.RIVER) { while (s != Street.RIVER) {
s = when (s) { s = when (s) {
@@ -314,6 +322,10 @@ class Table(
Street.TURN -> { dealRiver(); Street.RIVER } Street.TURN -> { dealRiver(); Street.RIVER }
Street.RIVER -> 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)
} }
} }
@@ -277,3 +277,80 @@ class HumanOfferAndStreetStateTest {
assertTrue(streetStarts.all { it.pot > 0 }, "the pot carries forward") 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<TableSnapshot> {
val seen = mutableListOf<TableSnapshot>()
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",
)
}
}
}