Fix four rules and modelling defects found in review
TDA Rule 47 — cumulative incomplete raises: A boolean could not express "facing at least a full raise since acting", so several short all-ins that together reached a full raise failed to reopen betting. Seat now records lastActedAtBet (the currentBet when it last acted); mayRaise() reopens when currentBet - lastActedAtBet >= minRaiseSize. This subsumes the single-incomplete-raise case, so the hasActed reset in apply() is gone. TDA Rule 20 — odd chips: Split-pot remainders were awarded in seat-list order. They now go to the first winning seat clockwise from the button. The old test also never produced an odd pot (20 chips heads-up), so it only ever proved an even split; it now builds a genuinely odd 25-chip pot via a folded small blind and asserts which seat takes the extra chip. OpponentModel skipped events across hands: It inferred a new hand from a shrinking history, but history is cleared each hand: having consumed 3 events, first observing the next hand at 4 events left 4 < 3 false and silently dropped the first three. observe() now takes an explicit handNumber, exposed via DecisionContext and Table.handNumber. PreflopChart percentile semantics: The 169 classes were ranked equally, but they are not equally likely — a pair is 6 of 1326 combinations, suited 4, offsuit 12. "Top 12%" therefore meant 12% of classes, not of dealt hands, so looseness did not mean what it claimed. Percentiles are now weighted by combination count. Tests: 30 -> 37. Each new test was verified to fail with its fix reverted. Skill gradient still monotonic: 85.9 / 79.8 / 17.4 / -183.1 bb/100 over 50k hands, chips conserved on both tables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,10 +56,24 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
|
||||
- Evaluator: verified exhaustively against published frequencies for all
|
||||
2,598,960 five-card hands. ~24M evals/sec.
|
||||
- Engine: chip-conserving; side pots, odd-chip splits, uncalled-bet refunds, and
|
||||
incomplete (short all-in) raises all covered by tests.
|
||||
- Bots: skill gradient **passes** monotonically (73.9 / 53.9 / 27.6 / −155.4
|
||||
- Engine: chip-conserving. Covered by tests: side pots, uncalled-bet refunds,
|
||||
action order, malformed agent output, **TDA Rule 47** (incomplete raises do not
|
||||
reopen betting, but several that cumulatively reach a full raise do), and
|
||||
**TDA Rule 20** (odd chip to the first winner left of the button).
|
||||
- Bots: skill gradient **passes** monotonically (85.9 / 79.8 / 17.4 / −183.1
|
||||
bb/100 at 50k hands).
|
||||
|
||||
### Rules invariants that are easy to get wrong
|
||||
|
||||
- Reopening betting cannot be a boolean. `Seat.lastActedAtBet` records the bet
|
||||
level a player last acted at; betting reopens when `currentBet - lastActedAtBet
|
||||
>= minRaiseSize`. Several short all-ins can reach that together.
|
||||
- `PreflopChart` percentiles are weighted by **combination counts** (pair 6,
|
||||
suited 4, offsuit 12, total 1326), so "top 12%" means 12% of *dealt hands*, not
|
||||
12% of the 169 classes.
|
||||
- Anything consuming `DecisionContext.history` across hands must key off
|
||||
`handNumber`. History is cleared each hand, so a size comparison silently drops
|
||||
events.
|
||||
- Known-imperfect: win-rate magnitudes are still ~10x realistic, and several
|
||||
profiles are looser than their labels (the Rock plays ~38% VPIP, should be
|
||||
~12%). Tuning is the open work.
|
||||
|
||||
@@ -48,7 +48,7 @@ class MathBot(
|
||||
val style = profile.style
|
||||
val opponents = ctx.activeOpponents.coerceAtLeast(1)
|
||||
|
||||
if (skill.readsOpponents) reads.observe(ctx.history)
|
||||
if (skill.readsOpponents) reads.observe(ctx.history, ctx.handNumber)
|
||||
if (ctx.street == Street.PREFLOP) return actPreflop(ctx)
|
||||
|
||||
// Weaker players run fewer rollouts, so they genuinely misjudge their hand
|
||||
|
||||
@@ -15,10 +15,21 @@ class OpponentModel {
|
||||
private val aggressiveActions = HashMap<Int, Int>()
|
||||
private val totalActions = HashMap<Int, Int>()
|
||||
private var consumed = 0
|
||||
private var currentHand = -1
|
||||
|
||||
/** Folds in any events not yet seen. History resets each hand, so detect that. */
|
||||
fun observe(history: List<HandEvent>) {
|
||||
if (history.size < consumed) consumed = 0
|
||||
/**
|
||||
* Folds in any events not yet seen.
|
||||
*
|
||||
* The hand number is required, not inferred. History is cleared each hand, so
|
||||
* a size comparison is not enough: if we consumed 3 events last hand and first
|
||||
* look at the next hand once it already has 4, `4 < 3` is false and the first
|
||||
* three events would be silently skipped.
|
||||
*/
|
||||
fun observe(history: List<HandEvent>, handNumber: Int) {
|
||||
if (handNumber != currentHand) {
|
||||
currentHand = handNumber
|
||||
consumed = 0
|
||||
}
|
||||
while (consumed < history.size) {
|
||||
val e = history[consumed++]
|
||||
totalActions[e.seat] = (totalActions[e.seat] ?: 0) + 1
|
||||
|
||||
@@ -129,12 +129,27 @@ object PreflopChart {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert raw equity into a percentile ranking.
|
||||
// Convert scores into a percentile weighted by how often each class is
|
||||
// actually dealt. The 169 classes are NOT equally likely — a pair is 6 of
|
||||
// the 1326 combinations, a suited hand 4, an offsuit hand 12 — so ranking
|
||||
// them evenly would make "top 12%" mean 12% of *classes* rather than 12%
|
||||
// of hands, which is what a player means by it.
|
||||
entries.sortByDescending { it.second }
|
||||
val out = DoubleArray(TABLE_SIZE) { 1.0 }
|
||||
for ((rank, entry) in entries.withIndex()) {
|
||||
out[entry.first] = rank.toDouble() / (entries.size - 1)
|
||||
var cumulative = 0
|
||||
for ((slot, _) in entries) {
|
||||
out[slot] = cumulative.toDouble() / TOTAL_COMBOS
|
||||
cumulative += combosForSlot(slot)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Combinations dealt for a slot: 6 for a pair, 4 suited, 12 offsuit. */
|
||||
private fun combosForSlot(slot: Int): Int = when {
|
||||
slot < 13 -> 6
|
||||
slot < 13 + 169 -> 4
|
||||
else -> 12
|
||||
}
|
||||
|
||||
private const val TOTAL_COMBOS = 1326 // C(52,2)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,17 @@ class Seat(
|
||||
var allIn = false
|
||||
var hasActed = false
|
||||
|
||||
/**
|
||||
* The table's `currentBet` at the moment this player last acted this round;
|
||||
* -1 before they have acted.
|
||||
*
|
||||
* A boolean cannot express TDA Rule 47: betting reopens when a player is
|
||||
* facing *at least a full raise* since their last action, which several
|
||||
* incomplete all-ins can reach cumulatively even though no single one does.
|
||||
* Storing the level they last acted at makes that a subtraction.
|
||||
*/
|
||||
var lastActedAtBet = -1
|
||||
|
||||
val canAct: Boolean get() = !folded && !allIn && stack > 0
|
||||
val contesting: Boolean get() = !folded
|
||||
}
|
||||
@@ -54,6 +65,10 @@ class DecisionContext(
|
||||
val seatsActingAfter: Int,
|
||||
val bigBlind: Int,
|
||||
val history: List<HandEvent>,
|
||||
/** Increments once per hand; use it to detect hand boundaries. */
|
||||
val handNumber: Int,
|
||||
/** TDA Rule 47: is this player facing at least a full raise since acting? */
|
||||
val bettingReopened: Boolean,
|
||||
) {
|
||||
val hole: IntArray get() = seat.hole
|
||||
val stack: Int get() = seat.stack
|
||||
@@ -62,11 +77,12 @@ class DecisionContext(
|
||||
/**
|
||||
* True when this player may still put in a raise.
|
||||
*
|
||||
* A player who has already acted at the current bet level and is only facing
|
||||
* an *incomplete* raise (a short all-in) owes the difference but may not
|
||||
* re-raise. A full raise resets [Seat.hasActed], restoring the right.
|
||||
* A player already acted at this level and facing only an *incomplete* raise
|
||||
* owes the difference but may not re-raise. Betting reopens once the increase
|
||||
* since their last action reaches a full raise — whether from one raise or
|
||||
* several short all-ins adding up.
|
||||
*/
|
||||
val canRaise: Boolean get() = seat.stack > toCall && !seat.hasActed
|
||||
val canRaise: Boolean get() = seat.stack > toCall && bettingReopened
|
||||
val inPosition: Boolean get() = seatsActingAfter == 0
|
||||
}
|
||||
|
||||
@@ -102,6 +118,10 @@ class Table(
|
||||
var button: Int = 0
|
||||
private set
|
||||
|
||||
/** Increments once per hand so stateful observers can detect hand boundaries. */
|
||||
var handNumber: Int = 0
|
||||
private set
|
||||
|
||||
val board = ArrayList<Int>(5)
|
||||
private val events = ArrayList<HandEvent>()
|
||||
|
||||
@@ -127,6 +147,7 @@ class Table(
|
||||
}
|
||||
|
||||
fun playHand(): HandResult {
|
||||
handNumber++
|
||||
val startingStacks = IntArray(seats.size) { seats[it].stack }
|
||||
resetForHand()
|
||||
|
||||
@@ -256,10 +277,19 @@ class Table(
|
||||
return actual
|
||||
}
|
||||
|
||||
/**
|
||||
* TDA Rule 47. A player who has not yet acted may always raise. One who has
|
||||
* may raise again only when the bet has climbed by at least a full raise
|
||||
* since they acted — which multiple incomplete all-ins can reach together.
|
||||
*/
|
||||
private fun mayRaise(seat: Seat): Boolean =
|
||||
seat.lastActedAtBet < 0 || (currentBet - seat.lastActedAtBet) >= minRaiseSize
|
||||
|
||||
private fun runBettingRound(street: Street, firstSeat: Int) {
|
||||
for (s in seats) {
|
||||
s.committedThisRound = 0
|
||||
s.hasActed = false
|
||||
s.lastActedAtBet = -1
|
||||
}
|
||||
|
||||
if (street == Street.PREFLOP) {
|
||||
@@ -292,6 +322,8 @@ class Table(
|
||||
val action = sanitise(seat, toCall, seat.agent.act(ctx))
|
||||
apply(street, seat, action, toCall)
|
||||
seat.hasActed = true
|
||||
// Record the level they acted at — after their own raise, if any.
|
||||
seat.lastActedAtBet = currentBet
|
||||
|
||||
if (seats.count { it.contesting } <= 1) return
|
||||
}
|
||||
@@ -329,6 +361,8 @@ class Table(
|
||||
seatsActingAfter = after,
|
||||
bigBlind = bigBlind,
|
||||
history = events,
|
||||
handNumber = handNumber,
|
||||
bettingReopened = mayRaise(seat),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -340,7 +374,7 @@ class Table(
|
||||
ActionType.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action
|
||||
ActionType.BET, ActionType.RAISE -> {
|
||||
// Facing only an incomplete raise after already acting: call or fold.
|
||||
if (seat.hasActed && toCall > 0) return Action(ActionType.CALL, toCall)
|
||||
if (!mayRaise(seat) && toCall > 0) return Action(ActionType.CALL, toCall)
|
||||
val maxTo = seat.committedThisRound + seat.stack
|
||||
val minTo = (currentBet + minRaiseSize).coerceAtMost(maxTo)
|
||||
val target = action.amount.coerceIn(minTo, maxTo)
|
||||
@@ -361,11 +395,11 @@ class Table(
|
||||
ActionType.BET, ActionType.RAISE -> {
|
||||
val raiseSize = action.amount - currentBet
|
||||
commit(seat, action.amount - seat.committedThisRound)
|
||||
// A short all-in that does not complete a full raise must not reopen betting.
|
||||
if (raiseSize >= minRaiseSize) {
|
||||
minRaiseSize = raiseSize
|
||||
for (other in seats) if (other !== seat && other.canAct) other.hasActed = false
|
||||
}
|
||||
// Only a full raise raises the bar for subsequent raises. An
|
||||
// incomplete all-in leaves minRaiseSize alone, but still lifts
|
||||
// currentBet — so it counts toward reopening cumulatively, which
|
||||
// mayRaise() computes from each seat's lastActedAtBet.
|
||||
if (raiseSize >= minRaiseSize) minRaiseSize = raiseSize
|
||||
currentBet = maxOf(currentBet, seat.committedThisRound)
|
||||
}
|
||||
}
|
||||
@@ -428,7 +462,12 @@ class Table(
|
||||
|
||||
for (p in pots) {
|
||||
val best = p.eligible.maxOf { scores.getValue(it) }
|
||||
val potWinners = p.eligible.filter { scores.getValue(it) == best }
|
||||
// TDA Rule 20: the odd chip goes to the first winning seat left of
|
||||
// the button, so order winners clockwise from there rather than by
|
||||
// seat-list index.
|
||||
val potWinners = p.eligible
|
||||
.filter { scores.getValue(it) == best }
|
||||
.sortedBy { (it - button - 1 + seats.size) % seats.size }
|
||||
val share = p.amount / potWinners.size
|
||||
var remainder = p.amount - share * potWinners.size
|
||||
for (w in potWinners) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.jsjdesigns.poker.bot
|
||||
|
||||
import com.jsjdesigns.poker.game.Action
|
||||
import com.jsjdesigns.poker.game.ActionType
|
||||
import com.jsjdesigns.poker.game.HandEvent
|
||||
import com.jsjdesigns.poker.game.Street
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class OpponentModelTest {
|
||||
|
||||
private fun bet(seat: Int) =
|
||||
HandEvent(Street.FLOP, seat, "P$seat", Action(ActionType.BET, 10))
|
||||
|
||||
private fun call(seat: Int) =
|
||||
HandEvent(Street.FLOP, seat, "P$seat", Action(ActionType.CALL, 10))
|
||||
|
||||
/**
|
||||
* The regression Codex found: history is cleared each hand, so a size
|
||||
* comparison cannot detect a hand boundary. Consume 3 events in hand 1, then
|
||||
* first observe hand 2 when it already holds 4 — a `4 < 3` check is false, so
|
||||
* the first three events of hand 2 would be silently dropped.
|
||||
*/
|
||||
@Test
|
||||
fun `events are not skipped when the next hand is first observed late`() {
|
||||
val model = OpponentModel()
|
||||
|
||||
model.observe(listOf(bet(1), call(2), call(3)), handNumber = 1)
|
||||
assertEquals(1, model.actionsObserved(1))
|
||||
assertEquals(1, model.actionsObserved(2))
|
||||
assertEquals(1, model.actionsObserved(3))
|
||||
|
||||
// New hand, already four events deep before we look.
|
||||
model.observe(listOf(bet(1), call(2), call(3), bet(1)), handNumber = 2)
|
||||
|
||||
assertEquals(3, model.actionsObserved(1), "both bets in hand 2 plus hand 1's must count")
|
||||
assertEquals(2, model.actionsObserved(2), "hand 2's first events must not be skipped")
|
||||
assertEquals(2, model.actionsObserved(3), "hand 2's first events must not be skipped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated observation within a hand is idempotent`() {
|
||||
val model = OpponentModel()
|
||||
val history = mutableListOf(bet(1))
|
||||
|
||||
model.observe(history, handNumber = 1)
|
||||
model.observe(history, handNumber = 1)
|
||||
model.observe(history, handNumber = 1)
|
||||
assertEquals(1, model.actionsObserved(1), "re-observing must not double count")
|
||||
|
||||
history += call(1)
|
||||
model.observe(history, handNumber = 1)
|
||||
assertEquals(2, model.actionsObserved(1), "growth within a hand is picked up")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aggression rate reflects bet and raise share once sampled`() {
|
||||
val model = OpponentModel()
|
||||
// 30 actions for seat 1: 24 bets, 6 calls.
|
||||
val history = ArrayList<HandEvent>()
|
||||
repeat(24) { history += bet(1) }
|
||||
repeat(6) { history += call(1) }
|
||||
model.observe(history, handNumber = 1)
|
||||
|
||||
assertEquals(30, model.actionsObserved(1))
|
||||
assertEquals(0.8, model.aggressionRate(1), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsampled opponents report a neutral read`() {
|
||||
val model = OpponentModel()
|
||||
model.observe(listOf(bet(1), bet(1)), handNumber = 1)
|
||||
assertEquals(0.5, model.aggressionRate(1), "too few actions to form a read")
|
||||
assertEquals(0.5, model.aggressionRate(9), "never seen at all")
|
||||
assertTrue(model.actionsObserved(9) == 0)
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,37 @@ class PreflopChartTest {
|
||||
assertTrue(p("7h 2d") - p("Ah Ad") > 0.8, "range is too compressed to gate on")
|
||||
}
|
||||
|
||||
/**
|
||||
* The percentile must be weighted by how often each class is dealt, not by
|
||||
* class count. The 169 classes are not equally likely — a pair is 6 of the
|
||||
* 1326 combinations, a suited hand 4, an offsuit hand 12 — so an unweighted
|
||||
* ranking would make "top 12%" mean 12% of classes, which is not what a
|
||||
* player means and would make `looseness` lie.
|
||||
*/
|
||||
@Test
|
||||
fun `a tight range admits few hands and a loose range admits many`() {
|
||||
fun `a range gate admits that share of actually dealt hands`() {
|
||||
val all = ArrayList<Double>()
|
||||
for (a in 0 until 51) for (b in a + 1 until 52) {
|
||||
all.add(PreflopChart.percentile(intArrayOf(a, b)))
|
||||
}
|
||||
val tight = all.count { it <= 0.12 }
|
||||
val loose = all.count { it <= 0.60 }
|
||||
assertTrue(tight < loose, "a 12% range must be narrower than a 60% range")
|
||||
assertTrue(tight > 0, "a 12% range must admit something")
|
||||
assertEquals(1326, all.size, "there are C(52,2) starting combinations")
|
||||
|
||||
for (gate in listOf(0.12, 0.25, 0.40, 0.60)) {
|
||||
val admitted = all.count { it <= gate }
|
||||
val share = admitted.toDouble() / all.size
|
||||
// Tolerance covers landing mid-class: one class can straddle the gate.
|
||||
assertTrue(
|
||||
kotlin.math.abs(share - gate) < 0.03,
|
||||
"gate $gate should admit ~${(gate * 100).toInt()}% of dealt hands, admitted ${"%.1f".format(share * 100)}%",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pairs are weighted as six combinations not one class`() {
|
||||
// Between AA (0.0) and KK there should be exactly 6/1326 of the range,
|
||||
// because AA occupies six combinations.
|
||||
val gap = p("Kh Kd") - p("Ah Ad")
|
||||
assertEquals(6.0 / 1326.0, gap, 1e-9, "AA must consume 6 combinations of the range")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,57 @@ class TableRulesTest {
|
||||
assertTrue(p0.offers[1].canRaise, "a full raise restores the right to re-raise")
|
||||
}
|
||||
|
||||
/**
|
||||
* TDA Rule 47: several incomplete all-ins that together add up to a full raise
|
||||
* DO reopen the betting, even though no single one of them would.
|
||||
*/
|
||||
@Test
|
||||
fun `cumulative short all-ins reopen betting once they total a full raise`() {
|
||||
// P3 opens to 100 (min raise size becomes 80). Two short all-ins follow,
|
||||
// 40 each: neither reopens alone, but together they reach 80.
|
||||
val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 80))
|
||||
val p0 = Scripted(Action(ActionType.RAISE, 140)) // all-in, +40
|
||||
val p1 = Scripted(Action(ActionType.RAISE, 180)) // all-in, +40
|
||||
val p2 = Scripted(Action(ActionType.FOLD))
|
||||
|
||||
val seats = listOf(
|
||||
Seat(0, "P0", 140, p0),
|
||||
Seat(1, "P1", 180, p1),
|
||||
Seat(2, "P2", 1000, p2),
|
||||
Seat(3, "P3", 1000, p3),
|
||||
)
|
||||
Table(seats, 10, 20, Random(1),
|
||||
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd"), "2c 7d 9s Jc 3h")).playHand()
|
||||
|
||||
assertEquals(2, p3.offers.size, "P3 should face the raised action again")
|
||||
assertEquals(80, p3.offers[1].toCall, "P3 owes 180 - 100")
|
||||
assertTrue(
|
||||
p3.offers[1].canRaise,
|
||||
"two 40-chip short all-ins total a full 80 raise, which reopens betting",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cumulative short all-ins below a full raise still do not reopen`() {
|
||||
// Same shape, but the shorts total only 40 against a min raise of 80.
|
||||
val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 40))
|
||||
val p0 = Scripted(Action(ActionType.RAISE, 120)) // all-in, +20
|
||||
val p1 = Scripted(Action(ActionType.RAISE, 140)) // all-in, +20
|
||||
val p2 = Scripted(Action(ActionType.FOLD))
|
||||
|
||||
val seats = listOf(
|
||||
Seat(0, "P0", 120, p0),
|
||||
Seat(1, "P1", 140, p1),
|
||||
Seat(2, "P2", 1000, p2),
|
||||
Seat(3, "P3", 1000, p3),
|
||||
)
|
||||
Table(seats, 10, 20, Random(1),
|
||||
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd", "Jh Jd"), "2c 7d 9s Jc 3h")).playHand()
|
||||
|
||||
assertEquals(2, p3.offers.size)
|
||||
assertFalse(p3.offers[1].canRaise, "40 total is short of a full 80 raise")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an illegal raise attempt is downgraded to a call`() {
|
||||
// P0 tries to re-raise after only an incomplete all-in; engine must clamp it.
|
||||
@@ -131,21 +182,35 @@ class TableRulesTest {
|
||||
assertTrue(result.net[2] < 0, "queens should lose")
|
||||
}
|
||||
|
||||
/**
|
||||
* A genuinely ODD pot: the folded small blind's 5 chips make 25, which two
|
||||
* winners cannot split evenly. TDA Rule 20 sends the odd chip to the first
|
||||
* winning seat left of the button.
|
||||
*/
|
||||
@Test
|
||||
fun `split pots conserve odd chips`() {
|
||||
// P0 and P1 play the same board; the pot must split without losing a chip.
|
||||
fun `odd chip in a split pot goes to the first winner left of the button`() {
|
||||
val p0 = Scripted(Action(ActionType.CALL, 10), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
||||
val p1 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
||||
val p1 = Scripted(Action(ActionType.FOLD))
|
||||
val p2 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
||||
|
||||
val seats = listOf(Seat(0, "P0", 501, p0), Seat(1, "P1", 501, p1))
|
||||
// Button is seat 0 -> SB seat 1 (folds, forfeiting 5), BB seat 2.
|
||||
val seats = listOf(Seat(0, "P0", 500, p0), Seat(1, "P1", 500, p1), Seat(2, "P2", 500, p2))
|
||||
val result = Table(
|
||||
seats, 5, 10, Random(1),
|
||||
// Board plays: both hold rags, the royal flush on board is the hand.
|
||||
StackedDeck.of(listOf("2c 3d", "2h 3s"), "Ah Kh Qh Jh Th"),
|
||||
// Royal flush on board: everyone left plays the board and ties.
|
||||
StackedDeck.of(listOf("2c 3d", "4c 5d", "2h 3s"), "Ah Kh Qh Jh Th"),
|
||||
).playHand()
|
||||
|
||||
assertEquals(0, result.net.sum(), "odd chips must not vanish")
|
||||
assertEquals(25, result.potSize, "pot must actually be odd for this test to mean anything")
|
||||
assertEquals(1, result.potSize % 2, "pot must be odd")
|
||||
assertEquals(0, result.net.sum(), "odd chips must not vanish or be invented")
|
||||
assertEquals(2, result.winners.size, "board plays -> split pot")
|
||||
|
||||
// Winners are seats 0 and 2. Clockwise from the button (seat 0), seat 2
|
||||
// comes first, so seat 2 takes the extra chip.
|
||||
assertEquals(3, result.net[2], "seat left of the button gets the odd chip")
|
||||
assertEquals(2, result.net[0], "the other winner gets the smaller share")
|
||||
assertEquals(-5, result.net[1], "the folded small blind loses its post")
|
||||
}
|
||||
|
||||
// ---------- uncalled bets ----------
|
||||
|
||||
Reference in New Issue
Block a user