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