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:
Jay
2026-07-25 21:59:26 -04:00
parent 4f1ef45dd7
commit c0bf57bf8d
5 changed files with 92 additions and 12 deletions
@@ -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,
@@ -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))