Fix legal-raise omission, hand header, and decision identity

1. Raise could vanish when it was legal. The action bar required
   maxRaiseTo > minRaiseTo, which hid an exact-minimum raise and any legal short
   all-in — both of which the engine accepts (sizeBet clamps a stack too short
   for a full min-raise to maxRaiseTo). The button now shows whenever the engine
   would accept a raise; the slider only appears when there is a genuine range,
   and a single-size raise submits a fixed amount.

2. The header used handsPlayed + 1, which increments when a hand *finishes*, so
   during the showdown hold it labelled hand 1's result as "Hand 2". It now uses
   snapshot.handNumber, which is by construction the hand being displayed.

3. Decision identity is now owned by the engine. HumanAgent minted its own
   tokens, so liveOffer() had to approximate matching with hand + street + seat
   — ambiguous, because a player can face two decisions on one street (bet, get
   raised, act again). Table stamps one monotonic token per decision, carried on
   both DecisionContext and TableSnapshot.toActToken, so liveOffer() matches
   exactly and the residual race is gone rather than narrowed.

4. Presentation logic moved out of the composable into buttonsFor(), a pure
   function of the offer, so it is testable without a Compose runtime. The app
   module had no tests at all; it now has 13 covering raise visibility, fold
   suppression, labels, and every liveOffer() matching case.

Each new test was verified to fail with its fix reverted: reverting raise
visibility and token matching failed exactly four, and no others.

Tests: 62 -> 141 total (64 engine JVM, 64 Android host, 13 app).
Simulation unchanged, chips conserved. Verified on the emulator; physical device
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 21:48:07 -04:00
parent 7382bc8638
commit f0c8a7c430
10 changed files with 353 additions and 30 deletions
@@ -24,7 +24,6 @@ class HumanAgent : PlayerAgent {
private val lock = Mutex()
private var pending: CompletableDeferred<Action>? = null
private var pendingToken = 0L
private var nextToken = 1L
private val _offer = MutableStateFlow<DecisionOffer?>(null)
@@ -35,11 +34,12 @@ class HumanAgent : PlayerAgent {
override suspend fun act(ctx: DecisionContext): Action {
val deferred = CompletableDeferred<Action>()
val token: Long
// The engine owns decision identity, so the offer and the snapshot that
// announced this turn carry the same token.
val token = ctx.decisionToken
lock.withLock {
check(pending == null) { "already awaiting a decision for this agent" }
pending = deferred
token = nextToken++
pendingToken = token
}
// Published after the deferred is installed, so a UI that reacts instantly
@@ -67,6 +67,14 @@ class DecisionContext(
val history: List<HandEvent>,
/** Increments once per hand; use it to detect hand boundaries. */
val handNumber: Int,
/**
* Identifies this specific decision, table-wide and monotonic.
*
* Owned by the engine so a snapshot and the decision it invites carry the
* same value; matching on hand and street alone is ambiguous, because a
* player can face two decisions on one street.
*/
val decisionToken: Long,
/** TDA Rule 47: is this player facing at least a full raise since acting? */
val bettingReopened: Boolean,
) {
@@ -139,6 +147,8 @@ class Table(
private var minRaiseSize = 0
private var currentStreet = Street.PREFLOP
private val revealed = HashSet<Int>()
private var decisionSeq = 0L
private var pendingToken = 0L
private fun snapshot(phase: TableSnapshot.Phase, toAct: Int?, potOverride: Int?) = TableSnapshot(
handNumber = handNumber,
@@ -167,6 +177,7 @@ class Table(
)
},
toAct = toAct,
toActToken = if (toAct != null) pendingToken else null,
lastAction = events.lastOrNull(),
)
@@ -406,7 +417,9 @@ class Table(
val seat = seats[i]
if (seat.canAct && (!seat.hasActed || seat.committedThisRound < currentBet)) {
val toCall = (currentBet - seat.committedThisRound).coerceAtLeast(0)
// Publish before asking, so the UI can show whose turn it is.
// One token per decision, stamped before the snapshot so the frame
// and the offer it invites carry the same identity.
pendingToken = ++decisionSeq
emit(TableSnapshot.Phase.BETTING, toAct = seat.index)
val ctx = buildContext(street, seat, toCall)
val action = sanitise(seat, toCall, seat.agent.act(ctx))
@@ -453,6 +466,7 @@ class Table(
bigBlind = bigBlind,
history = events,
handNumber = handNumber,
decisionToken = pendingToken,
bettingReopened = mayRaise(seat),
)
}
@@ -22,6 +22,8 @@ data class TableSnapshot(
val seats: List<SeatSnapshot>,
/** Seat currently owed an action, or null between streets and at showdown. */
val toAct: Int?,
/** Identity of the decision [toAct] is being asked for; null when nobody is. */
val toActToken: Long?,
val lastAction: HandEvent?,
) {
enum class Phase { DEALT, BETTING, STREET_COMPLETE, SHOWDOWN, COMPLETE }
@@ -32,7 +32,7 @@ private class FoldsOnce : PlayerAgent {
class FoldAndTokenTest {
private fun context(seat: Seat, canCheck: Boolean = false) = DecisionContext(
private fun context(seat: Seat, canCheck: Boolean = false, token: Long = 1L) = DecisionContext(
street = Street.FLOP,
seat = seat,
board = IntArray(0),
@@ -45,6 +45,7 @@ class FoldAndTokenTest {
bigBlind = 10,
history = emptyList(),
handNumber = 1,
decisionToken = token,
bettingReopened = true,
)
@@ -111,14 +112,14 @@ class FoldAndTokenTest {
val human = HumanAgent()
val seat = Seat(0, "You", 500, human)
val first = async { human.act(context(seat)) }
val first = async { human.act(context(seat, token = 10L)) }
while (!human.isAwaitingInput) yield()
val staleToken = human.offer.value!!.token
assertTrue(human.submit(staleToken, Action(ActionType.FOLD)))
first.await()
// A second decision arrives with a new token.
val second = async { human.act(context(seat)) }
val second = async { human.act(context(seat, token = 11L)) }
while (!human.isAwaitingInput) yield()
val freshToken = human.offer.value!!.token
assertNotEquals(staleToken, freshToken, "each decision gets its own token")
@@ -151,8 +152,8 @@ class FoldAndTokenTest {
val seat = Seat(0, "You", 500, human)
val seen = mutableListOf<Long>()
repeat(3) {
val d = async { human.act(context(seat)) }
repeat(3) { i ->
val d = async { human.act(context(seat, token = 100L + i)) }
while (!human.isAwaitingInput) yield()
val t = human.offer.value!!.token
seen += t
@@ -163,3 +164,56 @@ class FoldAndTokenTest {
assertEquals(seen.sorted(), seen, "and should advance monotonically")
}
}
/** The token has to be the same value on both sides of the UI boundary. */
class EngineTokenIdentityTest {
@Test
fun `the snapshot announcing a turn carries the same token as the offer`() = runTest {
val seen = mutableListOf<TableSnapshot>()
val human = HumanAgent()
val seats = listOf(Seat(0, "You", 500, human), Seat(1, "Bot", 500, AlwaysChecks()))
val table = Table(
seats, 5, 10, Random(1),
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
observer = { seen += it },
)
val hand = kotlinx.coroutines.CoroutineScope(kotlin.coroutines.EmptyCoroutineContext).let {
async { table.playHand() }
}
while (!human.isAwaitingInput) yield()
val offerToken = human.offer.value!!.token
val announcing = seen.last { it.toAct == 0 }
assertEquals(
announcing.toActToken, offerToken,
"the frame that says it is your turn must identify the same decision",
)
human.submit(offerToken, Action(ActionType.FOLD))
hand.await()
}
@Test
fun `every decision in a hand gets a distinct token`() = runTest {
val seen = mutableListOf<TableSnapshot>()
val seats = listOf(
Seat(0, "A", 500, AlwaysChecks()),
Seat(1, "B", 500, AlwaysChecks()),
Seat(2, "C", 500, AlwaysChecks()),
)
Table(
seats, 5, 10, Random(1),
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
observer = { seen += it },
).playHand()
val tokens = seen.mapNotNull { it.toActToken }
assertTrue(tokens.size > 3, "several decisions should have occurred")
assertEquals(tokens.distinct().sorted(), tokens.distinct(), "tokens advance")
// Each announcing frame is a distinct decision.
val announcing = seen.filter { it.toAct != null }.mapNotNull { it.toActToken }
assertEquals(announcing.size, announcing.distinct().size, "no token reused")
}
}
@@ -35,7 +35,7 @@ private class Raiser(private val to: Int) : PlayerAgent {
class SnapshotAndHumanAgentTest {
private fun context(seat: Seat) = DecisionContext(
private fun context(seat: Seat, token: Long = 1L) = DecisionContext(
street = Street.PREFLOP,
seat = seat,
board = IntArray(0),
@@ -48,6 +48,7 @@ class SnapshotAndHumanAgentTest {
bigBlind = 10,
history = emptyList(),
handNumber = 1,
decisionToken = token,
bettingReopened = true,
)
@@ -200,7 +201,7 @@ class SnapshotAndHumanAgentTest {
class HumanOfferAndStreetStateTest {
private fun context(seat: Seat) = DecisionContext(
private fun context(seat: Seat, token: Long = 1L) = DecisionContext(
street = Street.PREFLOP,
seat = seat,
board = IntArray(0),
@@ -213,6 +214,7 @@ class HumanOfferAndStreetStateTest {
bigBlind = 10,
history = emptyList(),
handNumber = 1,
decisionToken = token,
bettingReopened = true,
)