diff --git a/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt b/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt index 1674464..d42b977 100644 --- a/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt +++ b/app/src/main/java/com/jsjdesigns/poker/ActionButtons.kt @@ -31,17 +31,16 @@ fun buttonsFor(offer: DecisionOffer): ActionButtons { val low = if (shoveOnly) offer.maxRaiseTo else offer.minRaiseTo val high = offer.maxRaiseTo - // Facing more than you have left is a call for your whole stack: the engine - // clamps the commitment, so quoting the full price promises something the - // player cannot pay and does not owe. - val effectiveCall = minOf(offer.toCall, offer.stack) + // Sourced from the offer, not recomputed: the label, the submitted action and + // the engine must all agree on one number. + val effectiveCall = offer.callAmount return ActionButtons( // Folding a free hand is never correct, so don't invite an accidental muck. showFold = !offer.canCheck, checkOrCallLabel = when { offer.canCheck -> "Check" - effectiveCall >= offer.stack -> "All in $effectiveCall" + offer.callIsAllIn -> "All in $effectiveCall" else -> "Call $effectiveCall" }, effectiveCall = effectiveCall, diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index 844cd48..c1a7acc 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -153,10 +153,8 @@ class PokerViewModel : ViewModel() { fun checkOrCall(token: Long) { val o = offer.value ?: return if (o.token != token) return - // Send what the button promised. The engine would clamp an oversized call - // anyway, but the submitted action should not disagree with the label. - val cost = minOf(o.toCall, o.stack) - submit(token, if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, cost)) + // offer.callAmount is the one clamped value the label also renders. + submit(token, if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.callAmount)) } fun raiseTo(token: Long, amount: Int) = submit(token, Action(ActionType.RAISE, amount)) 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 a98bc47..71841bc 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt @@ -30,8 +30,18 @@ data class DecisionOffer( val canCheck: Boolean, val canRaise: Boolean, ) { - /** Convenience for a call-or-check button label. */ - val callAmount: Int get() = toCall + /** + * What calling actually costs. + * + * [toCall] is what is owed; a short stack commits only what it has, because + * the engine clamps the commitment. This is the single source of truth — the + * label shown and the action submitted must both come from here, or they + * drift apart and the UI starts promising prices the player cannot pay. + */ + val callAmount: Int get() = minOf(toCall, stack) + + /** True when calling would put this player all in. */ + val callIsAllIn: Boolean get() = toCall >= stack } fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer( 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 5177e3f..dc2f1d9 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -480,7 +480,12 @@ class Table( // 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 + // 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.BET, ActionType.RAISE -> { // Facing only an incomplete raise after already acting: call or fold. if (!mayRaise(seat) && toCall > 0) return Action(ActionType.CALL, toCall) 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 2923832..9c77b2f 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/FoldAndTokenTest.kt @@ -217,3 +217,71 @@ class EngineTokenIdentityTest { assertEquals(announcing.size, announcing.distinct().size, "no token reused") } } + +/** + * Hand history is read by replay and, later, by the coach. It has to describe + * chips that actually moved. + */ +class CallAmountHonestyTest { + + @Test + fun `a call is recorded at what was committed, not what was owed`() = runTest { + // Heads-up, seat 1 is the big blind: 10 is already posted, so of its 40 the + // call itself can only commit the remaining 30. + val seats = listOf( + Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 100) }), + Seat(1, "B", 40, PlayerAgent { ctx -> Action(ActionType.CALL, ctx.toCall) }), + ) + 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, + "history must record the 30 the call actually committed, not the 90 owed", + ) + assertTrue(seats[1].allIn, "the short stack is all in") + assertEquals(0, result.net.sum(), "chips still conserve") + } + + @Test + fun `an affordable call is recorded at full price`() = runTest { + val seats = listOf( + Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 100) }), + Seat(1, "B", 1000, PlayerAgent { ctx -> Action(ActionType.CALL, ctx.toCall) }), + ) + 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(90, call.action.amount, "the big blind's 10 is already in, so 90 completes the 100") + } + + @Test + fun `the offer reports what a call really costs`() = runTest { + val human = HumanAgent() + val seats = listOf( + Seat(0, "A", 1000, PlayerAgent { Action(ActionType.RAISE, 300) }), + Seat(1, "You", 60, human), + ) + val table = Table( + seats, 5, 10, Random(1), + StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"), + ) + val hand = async { table.playHand() } + while (!human.isAwaitingInput) yield() + + val o = human.offer.value!! + assertTrue(o.toCall > o.stack, "this setup should face more than the stack") + assertEquals(o.stack, o.callAmount, "callAmount is capped at the stack") + assertTrue(o.callIsAllIn, "and is flagged as an all-in call") + + human.submit(o.token, Action(ActionType.CALL, o.callAmount)) + val result = hand.await() + assertEquals(0, result.net.sum()) + } +}