Route every call-resolving path through canonicalCall
Direct calls were canonicalised last commit, but the two downgrade paths inside the BET/RAISE branch still built Action(CALL, toCall) with the unclamped amount: an illegal raise falling back to a call, and a player barred from re-raising after an incomplete all-in. A short stack there recorded chips that never moved. That path matters more than the direct one — it is where malformed agent output lands, which is precisely what an LLM will produce once the persona layer exists. All three now go through one canonicalCall(seat, toCall). There is exactly one construction of a CALL action left in the file, which is the point: three copies of this rule is how it went wrong twice. Regressions for both paths, each verified to fail with only its own path reverted — recording 90 instead of 30, and 30 instead of 20. Tests: 150 -> 154 (69 engine JVM, 69 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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user