One clamp, one contract: callAmount owns what a call costs
The visible label was right but the contract behind it was not. The clamp lived in three places — DecisionOffer.callAmount (unclamped, so it still reported the full amount owed), buttonsFor(), and PokerViewModel — and only the presentation copy was tested. Three copies of a rule is how the original mismatch happened. DecisionOffer.callAmount is now minOf(toCall, stack), with callIsAllIn beside it, and both the label and the submitted action read from it. Nothing recomputes the clamp. Engine sanitisation also left an oversized CALL amount unchanged in HandEvent even though commit() caps the commitment, so history described chips that never moved. Since replay and the coach both read that history, calls are now recorded at what was actually committed. Note the arithmetic: heads-up, seat 1 is the big blind, so with a 40 stack facing a raise to 100 the call commits the remaining 30, not 40. My first test asserted 40 and the engine was right. Verified by reverting: both contract fixes fail their tests. Tests: 144 -> 150 (67 engine JVM, 67 Android host, 16 app). Simulation figures unchanged (289.12 / 113.19 / -195.64), chips conserved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user