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 dc2f1d9..8cf46de 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -471,6 +471,20 @@ class Table( ) } + /** + * The single way a call is expressed. + * + * `commit()` caps a short stack, so recording the full amount owed would put + * chips in the hand history that never moved. Every path that resolves to a + * call goes through here — a direct call, an illegal raise downgraded to one, + * and a raise whose sanitised target cannot beat the current bet — because + * three copies of this rule is how it went wrong the first time. That matters + * most for malformed agent output, which is exactly what an LLM will produce. + */ + private fun canonicalCall(seat: Seat, toCall: Int): Action = + if (toCall == 0) Action(ActionType.CHECK) + else Action(ActionType.CALL, minOf(toCall, seat.stack)) + /** Clamps whatever an agent returns into something legal. */ private fun sanitise(seat: Seat, toCall: Int, action: Action): Action { return when (action.type) { @@ -480,20 +494,15 @@ class Table( // asked to act. Never silently substitute a different action. ActionType.FOLD -> action ActionType.CHECK -> if (toCall > 0) Action(ActionType.FOLD) else action - // Record what is actually committed, not what was owed. commit() caps a - // short stack, so an unclamped amount would leave hand history — which - // replay and the coach both read — describing chips that never moved. - ActionType.CALL -> - if (toCall == 0) Action(ActionType.CHECK) - else Action(ActionType.CALL, minOf(toCall, seat.stack)) + ActionType.CALL -> canonicalCall(seat, toCall) ActionType.BET, ActionType.RAISE -> { // Facing only an incomplete raise after already acting: call or fold. - if (!mayRaise(seat) && toCall > 0) return Action(ActionType.CALL, toCall) + if (!mayRaise(seat) && toCall > 0) return canonicalCall(seat, toCall) val maxTo = seat.committedThisRound + seat.stack val minTo = (currentBet + minRaiseSize).coerceAtMost(maxTo) val target = action.amount.coerceIn(minTo, maxTo) if (target <= currentBet) { - if (toCall == 0) Action(ActionType.CHECK) else Action(ActionType.CALL, toCall) + canonicalCall(seat, toCall) } else { Action(if (currentBet == 0) ActionType.BET else ActionType.RAISE, target) } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt index 9c77b2f..c602d93 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt @@ -285,3 +285,54 @@ class CallAmountHonestyTest { assertEquals(0, result.net.sum()) } } + +/** + * Every path that resolves to a call must record what was committed, not what + * was owed — including the two downgrade paths, which is where malformed agent + * output lands. An LLM will produce exactly that kind of output. + */ +class DowngradedCallHonestyTest { + + @Test + fun `an illegal raise from a short stack records only its remaining chips`() = runTest { + val seats = listOf( + Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 100) }), + // 40 total, 10 of it already posted as the big blind: 30 behind. + Seat(1, "B", 40, PlayerAgent { Action(ActionType.RAISE, 500) }), + ) + val result = Table( + seats, 5, 10, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"), + ).playHand() + val call = result.events.first { it.seat == 1 && it.action.type == ActionType.CALL } + assertEquals( + 30, call.action.amount, + "the illegal raise downgraded to a call must record the 30 actually committed", + ) + assertTrue(seats[1].allIn) + assertEquals(0, result.net.sum()) + } + + @Test + fun `a short stack barred from re-raising records only its remaining chips`() = runTest { + // P0 opens to 100 leaving 20 behind, P1 shoves short (does not reopen), so + // P0 may no longer raise. It tries anyway and must be downgraded honestly. + val seats = listOf( + Seat(0, "P0", 120, PlayerAgent { Action(ActionType.RAISE, 100) }), + Seat(1, "P1", 130, PlayerAgent { Action(ActionType.RAISE, 130) }), + Seat(2, "P2", 1000, PlayerAgent { Action(ActionType.FOLD) }), + ) + val result = Table( + seats, 10, 20, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"), + ).playHand() + + val calls = result.events.filter { it.seat == 0 && it.action.type == ActionType.CALL } + assertTrue(calls.isNotEmpty(), "P0 should have been downgraded to a call") + assertEquals( + 20, calls.last().action.amount, + "P0 owes 30 but has only 20 behind; history must say 20", + ) + assertEquals(0, result.net.sum()) + } +}