From f1222401fbe72c8fbe405930ecf246ae4f8de33b Mon Sep 17 00:00:00 2001 From: Jay Date: Sat, 25 Jul 2026 05:03:11 -0400 Subject: [PATCH] Fix four rules and modelling defects found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 20 ++++- .../com/jsjdesigns/poker/bot/MathBot.kt | 2 +- .../com/jsjdesigns/poker/bot/OpponentModel.kt | 17 +++- .../com/jsjdesigns/poker/core/PreflopChart.kt | 21 ++++- .../kotlin/com/jsjdesigns/poker/game/Table.kt | 61 +++++++++++--- .../jsjdesigns/poker/bot/OpponentModelTest.kt | 78 ++++++++++++++++++ .../jsjdesigns/poker/core/PreflopChartTest.kt | 32 ++++++-- .../jsjdesigns/poker/game/TableRulesTest.kt | 79 +++++++++++++++++-- 8 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/OpponentModelTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index b414bcb..7cbe5ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt index fc845af..7e5a4f8 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt @@ -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 diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt index 0a42bad..8865cb8 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/OpponentModel.kt @@ -15,10 +15,21 @@ class OpponentModel { private val aggressiveActions = HashMap() private val totalActions = HashMap() 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) { - 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, 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 diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt index a3e778e..528cadb 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/core/PreflopChart.kt @@ -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) } diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt index 6f59a27..6d5bca7 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -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, + /** 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(5) private val events = ArrayList() @@ -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) { diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/OpponentModelTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/OpponentModelTest.kt new file mode 100644 index 0000000..54962af --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/OpponentModelTest.kt @@ -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() + 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) + } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt index 956e067..9d2ee68 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/core/PreflopChartTest.kt @@ -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() 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") } } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt index a36c43d..ec6ac08 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt @@ -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 ----------