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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user