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
@@ -0,0 +1,46 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.game.DecisionOffer
/**
* What the action bar should show for a decision.
*
* Kept as a pure function of the offer so it can be unit-tested without a Compose
* runtime. The UI only renders this; it makes no decisions of its own.
*/
data class ActionButtons(
val showFold: Boolean,
val checkOrCallLabel: String,
val showRaise: Boolean,
/** False when there is exactly one legal raise size, so a slider is pointless. */
val showSlider: Boolean,
val sliderMin: Int,
val sliderMax: Int,
/** The amount to submit when the slider is hidden. */
val fixedRaiseTo: Int,
)
fun buttonsFor(offer: DecisionOffer): ActionButtons {
// When a stack cannot cover a full min-raise it may still shove; the engine
// clamps such a raise to maxRaiseTo. Requiring maxRaiseTo > minRaiseTo to show
// the button hid both that short all-in AND an exact-minimum raise, even
// though the engine accepts both.
val shoveOnly = offer.maxRaiseTo <= offer.minRaiseTo
val low = if (shoveOnly) offer.maxRaiseTo else offer.minRaiseTo
val high = offer.maxRaiseTo
return ActionButtons(
// Folding a free hand is never correct, so don't invite an accidental muck.
showFold = !offer.canCheck,
checkOrCallLabel = if (offer.canCheck) "Check" else "Call ${offer.toCall}",
showRaise = offer.canRaise,
showSlider = high > low,
sliderMin = low,
sliderMax = high,
fixedRaiseTo = low,
)
}
/** Label for the raise button at [amount]. */
fun raiseLabel(buttons: ActionButtons, amount: Int): String =
if (amount >= buttons.sliderMax) "All in" else "Raise $amount"
@@ -44,11 +44,10 @@ data class UiState(
fun liveOffer(offer: DecisionOffer?): DecisionOffer? {
val snap = snapshot ?: return null
if (offer == null) return null
return offer.takeIf {
it.handNumber == snap.handNumber &&
snap.toAct == HERO_SEAT &&
it.street == snap.street
}
// Match on the engine's decision token, not hand+street. A player can face
// two decisions on one street (bet, get raised, act again), so hand+street
// is ambiguous and would briefly pair a new offer with a stale board.
return offer.takeIf { snap.toActToken == it.token }
}
}
@@ -57,7 +57,10 @@ fun TableScreen(vm: PokerViewModel) {
.padding(12.dp),
) {
Text(
text = "Hand ${state.handsPlayed + 1} ${snap?.street ?: ""}",
// snapshot.handNumber, not handsPlayed + 1: the counter increments when
// the hand *finishes*, so during the showdown hold it would label the
// result of hand 1 as "Hand 2".
text = "Hand ${snap?.handNumber ?: 1} ${snap?.street ?: ""}",
color = Color.White.copy(alpha = 0.6f),
fontSize = 12.sp,
)
@@ -229,26 +232,25 @@ private fun ActionBar(
return
}
var raiseTo by remember { mutableIntStateOf(offer.minRaiseTo) }
// Reset the slider whenever a new decision arrives, or it keeps the old hand's value.
LaunchedEffect(offer) { raiseTo = offer.minRaiseTo }
val buttons = buttonsFor(offer)
var raiseTo by remember { mutableIntStateOf(buttons.fixedRaiseTo) }
// Reset whenever a new decision arrives, or it keeps the previous one's value.
LaunchedEffect(offer.token) { raiseTo = buttons.fixedRaiseTo }
Column(Modifier.fillMaxWidth()) {
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
if (buttons.showRaise && buttons.showSlider) {
Text("Raise to $raiseTo", color = Color.White, fontSize = 13.sp)
Slider(
value = raiseTo.toFloat(),
value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(),
onValueChange = { raiseTo = it.toInt() },
valueRange = offer.minRaiseTo.toFloat()..offer.maxRaiseTo.toFloat(),
valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// No Fold button when checking is free: folding a free hand is never
// correct, and offering it invites the player to muck by accident.
if (!offer.canCheck) {
if (buttons.showFold) {
Button(
onClick = { onFold(offer.token) },
modifier = Modifier.weight(1f),
@@ -260,14 +262,19 @@ private fun ActionBar(
onClick = { onCheckCall(offer.token) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
) { Text(if (offer.canCheck) "Check" else "Call ${offer.toCall}") }
) { Text(buttons.checkOrCallLabel) }
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
if (buttons.showRaise) {
val amount = if (buttons.showSlider) {
raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax)
} else {
buttons.fixedRaiseTo
}
Button(
onClick = { onRaise(offer.token, raiseTo) },
onClick = { onRaise(offer.token, amount) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
) { Text(if (raiseTo >= offer.maxRaiseTo) "All in" else "Raise") }
) { Text(raiseLabel(buttons, amount)) }
}
}
}
@@ -0,0 +1,99 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.game.DecisionOffer
import com.jsjdesigns.poker.game.Street
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ActionButtonsTest {
private fun offer(
toCall: Int = 10,
minRaiseTo: Int = 20,
maxRaiseTo: Int = 500,
canCheck: Boolean = false,
canRaise: Boolean = true,
stack: Int = 500,
) = DecisionOffer(
token = 1L,
handNumber = 1,
street = Street.FLOP,
seat = 0,
hole = listOf(0, 1),
board = emptyList(),
pot = 40,
toCall = toCall,
minRaiseTo = minRaiseTo,
maxRaiseTo = maxRaiseTo,
stack = stack,
canCheck = canCheck,
canRaise = canRaise,
)
// ---------- the raise omission ----------
/**
* The UI used to require maxRaiseTo > minRaiseTo, which hid an exact-minimum
* raise even though the engine accepts it.
*/
@Test
fun `an exact minimum raise is still offered`() {
val b = buttonsFor(offer(minRaiseTo = 100, maxRaiseTo = 100))
assertTrue("a raise with exactly one legal size must still be offered", b.showRaise)
assertFalse("no slider is useful for a single size", b.showSlider)
assertEquals(100, b.fixedRaiseTo)
}
/**
* A stack too short for a full min-raise may still shove; the engine clamps
* such a raise to maxRaiseTo. That was hidden too.
*/
@Test
fun `a short all-in is still offered`() {
val b = buttonsFor(offer(minRaiseTo = 200, maxRaiseTo = 120, stack = 120))
assertTrue("a legal short all-in must be offered", b.showRaise)
assertFalse(b.showSlider)
assertEquals(120, b.fixedRaiseTo)
assertEquals("All in", raiseLabel(b, b.fixedRaiseTo))
}
@Test
fun `a genuine range gets a slider`() {
val b = buttonsFor(offer(minRaiseTo = 20, maxRaiseTo = 500))
assertTrue(b.showRaise)
assertTrue(b.showSlider)
assertEquals(20, b.sliderMin)
assertEquals(500, b.sliderMax)
}
@Test
fun `no raise button when the engine would not accept one`() {
assertFalse(buttonsFor(offer(canRaise = false)).showRaise)
}
// ---------- fold and call ----------
@Test
fun `fold is hidden when checking is free`() {
assertFalse(
"folding a free hand is never correct; offering it invites an accidental muck",
buttonsFor(offer(toCall = 0, canCheck = true)).showFold,
)
assertTrue(buttonsFor(offer(toCall = 10, canCheck = false)).showFold)
}
@Test
fun `the call button names the price`() {
assertEquals("Call 35", buttonsFor(offer(toCall = 35)).checkOrCallLabel)
assertEquals("Check", buttonsFor(offer(toCall = 0, canCheck = true)).checkOrCallLabel)
}
@Test
fun `the raise label switches to all in at the top of the range`() {
val b = buttonsFor(offer(minRaiseTo = 20, maxRaiseTo = 500))
assertEquals("Raise 60", raiseLabel(b, 60))
assertEquals("All in", raiseLabel(b, 500))
}
}
@@ -0,0 +1,100 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.game.DecisionOffer
import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.Street
import com.jsjdesigns.poker.game.TableSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The action bar must never be rendered against a table it does not belong to.
*/
class LiveOfferTest {
private fun snapshot(
handNumber: Int = 1,
street: Street = Street.FLOP,
toAct: Int? = HERO_SEAT,
toActToken: Long? = 7L,
) = TableSnapshot(
handNumber = handNumber,
street = street,
phase = TableSnapshot.Phase.BETTING,
board = emptyList(),
pot = 40,
currentBet = 10,
minRaiseSize = 10,
button = 0,
seats = listOf(
SeatSnapshot(0, "You", 500, 0, 0, false, false, listOf(0, 1), false, true),
),
toAct = toAct,
toActToken = toActToken,
lastAction = null,
)
private fun offer(token: Long = 7L, handNumber: Int = 1, street: Street = Street.FLOP) =
DecisionOffer(
token = token,
handNumber = handNumber,
street = street,
seat = HERO_SEAT,
hole = listOf(0, 1),
board = emptyList(),
pot = 40,
toCall = 10,
minRaiseTo = 20,
maxRaiseTo = 500,
stack = 500,
canCheck = false,
canRaise = true,
)
@Test
fun `an offer matching the displayed decision is live`() {
val state = UiState(snapshot = snapshot(toActToken = 7L))
assertEquals(7L, state.liveOffer(offer(token = 7L))?.token)
}
@Test
fun `an offer from a later decision is withheld until its frame is shown`() {
// The engine has moved on; the board on screen is still the older one.
val state = UiState(snapshot = snapshot(toActToken = 7L))
assertNull(
"showing a newer offer against a stale table is what made actions look wrong",
state.liveOffer(offer(token = 8L)),
)
}
/**
* Hand+street matching was not enough: a player can face two decisions on the
* same street, e.g. bet, get raised, act again.
*/
@Test
fun `two decisions on the same street are distinguished`() {
val state = UiState(snapshot = snapshot(handNumber = 1, street = Street.FLOP, toActToken = 12L))
assertNull(
"same hand and street, different decision — must not match",
state.liveOffer(offer(token = 11L, handNumber = 1, street = Street.FLOP)),
)
assertEquals(12L, state.liveOffer(offer(token = 12L, handNumber = 1, street = Street.FLOP))?.token)
}
@Test
fun `nothing is live when no one is being asked to act`() {
val state = UiState(snapshot = snapshot(toAct = null, toActToken = null))
assertNull(state.liveOffer(offer()))
}
@Test
fun `nothing is live before the first frame arrives`() {
assertNull(UiState(snapshot = null).liveOffer(offer()))
}
@Test
fun `no offer means no action bar`() {
assertNull(UiState(snapshot = snapshot()).liveOffer(null))
}
}