Add deterministic post-action coach
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
package com.jsjdesigns.poker.bot
|
||||
|
||||
import com.jsjdesigns.poker.core.Equity
|
||||
import com.jsjdesigns.poker.core.PreflopChart
|
||||
import com.jsjdesigns.poker.game.Action
|
||||
import com.jsjdesigns.poker.game.ActionType
|
||||
import com.jsjdesigns.poker.game.DecisionOffer
|
||||
import com.jsjdesigns.poker.game.Street
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Offline review of one accepted human decision.
|
||||
*
|
||||
* This is a transparent fundamentals baseline, not a solver and not a disguised
|
||||
* bot persona. It receives only [DecisionOffer] — the immutable information the
|
||||
* human was allowed to know — so opponent hole cards cannot leak into advice.
|
||||
*/
|
||||
data class CoachingReview(
|
||||
val decisionToken: Long,
|
||||
val handNumber: Int,
|
||||
val street: Street,
|
||||
val trace: DecisionTrace,
|
||||
/** Same strategic action family; bet sizing differences are not condemned. */
|
||||
val alignedWithBaseline: Boolean,
|
||||
)
|
||||
|
||||
class DecisionCoach(
|
||||
private val equityIterations: Int = 3_000,
|
||||
) {
|
||||
init {
|
||||
require(equityIterations > 0) { "equity iterations must be positive" }
|
||||
}
|
||||
|
||||
fun review(offer: DecisionOffer, chosen: Action): CoachingReview {
|
||||
val trace = if (offer.street == Street.PREFLOP) {
|
||||
reviewPreflop(offer, chosen)
|
||||
} else {
|
||||
reviewPostflop(offer, chosen)
|
||||
}
|
||||
return CoachingReview(
|
||||
decisionToken = offer.token,
|
||||
handNumber = offer.handNumber,
|
||||
street = offer.street,
|
||||
trace = trace,
|
||||
alignedWithBaseline = sameActionFamily(trace.intended, chosen),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reviewPreflop(offer: DecisionOffer, chosen: Action): DecisionTrace {
|
||||
val percentile = PreflopChart.percentile(offer.hole.toIntArray())
|
||||
val positionFactor = balancedPositionFactor(
|
||||
activeOpponents = offer.activeOpponents,
|
||||
inPosition = offer.inPosition,
|
||||
awareness = 1.0,
|
||||
inPositionDelta = 0.45,
|
||||
)
|
||||
// What this seat still owes is not enough to identify a raise: the big
|
||||
// blind can face a min-raise while owing only one big blind.
|
||||
val facingRaise = offer.currentBet > offer.bigBlind
|
||||
val facingRaiseFactor = if (facingRaise) 0.45 else 1.0
|
||||
val range = (BASELINE_PREFLOP_RANGE * positionFactor * facingRaiseFactor)
|
||||
.coerceIn(0.01, 1.0)
|
||||
val raiseRange = range * BASELINE_RAISE_SHARE
|
||||
|
||||
val intended = when {
|
||||
percentile <= raiseRange && offer.canRaise ->
|
||||
aggressiveAction(offer, preflop = true, facingRaise = facingRaise)
|
||||
percentile <= range ->
|
||||
if (offer.canCheck) Action(ActionType.CHECK)
|
||||
else Action(ActionType.CALL, offer.callAmount)
|
||||
else ->
|
||||
if (offer.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
|
||||
}
|
||||
|
||||
return DecisionTrace(
|
||||
estimatedEquity = null,
|
||||
handStrengthPercentile = percentile,
|
||||
breakEvenEquity = null,
|
||||
decisionThreshold = null,
|
||||
preflopRangeThreshold = range,
|
||||
potOdds = null,
|
||||
intended = intended,
|
||||
chosen = chosen,
|
||||
// A human deviation is not a bot skill-error transform.
|
||||
mistakeApplied = false,
|
||||
adjustments = listOf(
|
||||
ThresholdAdjustment("position", positionFactor),
|
||||
ThresholdAdjustment("facing raise", facingRaiseFactor),
|
||||
),
|
||||
reason = buildString {
|
||||
append("Starting hand is in the top ${(percentile * 100).roundToInt()}%; ")
|
||||
append("the fundamentals baseline range here is ${(range * 100).roundToInt()}%.")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun reviewPostflop(offer: DecisionOffer, chosen: Action): DecisionTrace {
|
||||
val equity = Equity.estimate(
|
||||
hole = offer.hole.toIntArray(),
|
||||
board = offer.board.toIntArray(),
|
||||
opponents = offer.activeOpponents.coerceAtLeast(1),
|
||||
iterations = equityIterations,
|
||||
random = Random(seedFor(offer)),
|
||||
)
|
||||
val breakEven = Equity.potOdds(offer.eligiblePot, offer.callAmount)
|
||||
val intended = when {
|
||||
offer.canCheck && equity >= VALUE_BET_EQUITY && offer.canRaise ->
|
||||
aggressiveAction(offer, preflop = false, facingRaise = false)
|
||||
offer.canCheck ->
|
||||
Action(ActionType.CHECK)
|
||||
equity < breakEven ->
|
||||
Action(ActionType.FOLD)
|
||||
equity >= VALUE_RAISE_EQUITY && offer.canRaise ->
|
||||
aggressiveAction(offer, preflop = false, facingRaise = true)
|
||||
else ->
|
||||
Action(ActionType.CALL, offer.callAmount)
|
||||
}
|
||||
|
||||
return DecisionTrace(
|
||||
estimatedEquity = equity,
|
||||
handStrengthPercentile = null,
|
||||
breakEvenEquity = breakEven,
|
||||
decisionThreshold = breakEven,
|
||||
preflopRangeThreshold = null,
|
||||
potOdds = if (offer.toCall > 0) {
|
||||
"${offer.eligiblePot}:${offer.callAmount}"
|
||||
} else {
|
||||
"no bet to call"
|
||||
},
|
||||
intended = intended,
|
||||
chosen = chosen,
|
||||
mistakeApplied = false,
|
||||
adjustments = emptyList(),
|
||||
reason = buildString {
|
||||
append("About ${(equity * 100).roundToInt()}% equity against ")
|
||||
append("${offer.activeOpponents} unknown random hand(s)")
|
||||
if (offer.toCall > 0) {
|
||||
append("; raw pot odds require ${(breakEven * 100).roundToInt()}%.")
|
||||
} else {
|
||||
append(", with no bet to call.")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun aggressiveAction(
|
||||
offer: DecisionOffer,
|
||||
preflop: Boolean,
|
||||
facingRaise: Boolean,
|
||||
): Action {
|
||||
// A big blind may check pre-flop while still raising an existing blind.
|
||||
// Post-flop, a free aggressive action opens the betting.
|
||||
val type = if (preflop || !offer.canCheck) ActionType.RAISE else ActionType.BET
|
||||
if (offer.maxRaiseTo <= offer.minRaiseTo) {
|
||||
return Action(type, offer.maxRaiseTo)
|
||||
}
|
||||
val desired = when {
|
||||
preflop && !facingRaise -> offer.bigBlind * 3
|
||||
preflop -> offer.minRaiseTo + (offer.pot * 0.40).roundToInt()
|
||||
else -> offer.minRaiseTo + (offer.pot * 0.50).roundToInt()
|
||||
}
|
||||
return Action(type, desired.coerceIn(offer.minRaiseTo, offer.maxRaiseTo))
|
||||
}
|
||||
|
||||
private fun seedFor(offer: DecisionOffer): Int {
|
||||
var seed = 17
|
||||
seed = seed * 31 + offer.handNumber
|
||||
seed = seed * 31 + offer.street.ordinal
|
||||
seed = seed * 31 + offer.activeOpponents
|
||||
for (card in offer.hole) seed = seed * 31 + card
|
||||
for (card in offer.board) seed = seed * 31 + card
|
||||
return seed
|
||||
}
|
||||
|
||||
private fun sameActionFamily(recommended: Action, chosen: Action): Boolean {
|
||||
val recommendedAggressive =
|
||||
recommended.type == ActionType.BET || recommended.type == ActionType.RAISE
|
||||
val chosenAggressive =
|
||||
chosen.type == ActionType.BET || chosen.type == ActionType.RAISE
|
||||
return if (recommendedAggressive || chosenAggressive) {
|
||||
recommendedAggressive && chosenAggressive
|
||||
} else {
|
||||
recommended.type == chosen.type
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BASELINE_PREFLOP_RANGE = 0.22
|
||||
const val BASELINE_RAISE_SHARE = 0.59
|
||||
const val VALUE_BET_EQUITY = 0.62
|
||||
const val VALUE_RAISE_EQUITY = 0.68
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ import kotlin.random.Random
|
||||
/**
|
||||
* The numbers behind one decision.
|
||||
*
|
||||
* Kept deliberately explicit because this is exactly what the coach hands to the
|
||||
* LLM. The model narrates these values; it never computes them.
|
||||
* Kept deliberately explicit because the deterministic coach UI consumes this
|
||||
* contract directly, and a later LLM may narrate it. Neither consumer computes
|
||||
* or reconstructs poker maths.
|
||||
*/
|
||||
data class DecisionTrace(
|
||||
/** Monte Carlo equity when equity is actually estimated; null pre-flop. */
|
||||
@@ -30,7 +31,9 @@ data class DecisionTrace(
|
||||
val potOdds: String?,
|
||||
/** What the strategy selected before a skill error was applied. */
|
||||
val intended: Action,
|
||||
/** Action actually taken by the bot or human whose decision is reviewed. */
|
||||
val chosen: Action,
|
||||
/** True only when bot skill-error injection changed [intended]. */
|
||||
val mistakeApplied: Boolean,
|
||||
/** Multipliers applied to the raw threshold, in evaluation order. */
|
||||
val adjustments: List<ThresholdAdjustment>,
|
||||
@@ -99,7 +102,10 @@ class MathBot(
|
||||
val looseness = (style.looseness + mood.loosenessBonus()).coerceIn(0.0, 1.0)
|
||||
val aggression = (style.aggression + mood.aggressionBonus()).coerceIn(0.0, 1.0)
|
||||
|
||||
val breakEven = Equity.potOdds(ctx.pot, ctx.toCall)
|
||||
// A short stack cannot win side-pot chips above its final contribution.
|
||||
// Price the affordable call against only the pot this seat can contest.
|
||||
val affordableCall = minOf(ctx.toCall, ctx.stack)
|
||||
val breakEven = Equity.potOdds(ctx.eligiblePot, affordableCall)
|
||||
|
||||
// Pot odds are the honest baseline. In particular, the river has no
|
||||
// future street from which to earn "implied" chips, so a blanket discount
|
||||
@@ -161,7 +167,7 @@ class MathBot(
|
||||
breakEvenEquity = breakEven,
|
||||
decisionThreshold = bar,
|
||||
preflopRangeThreshold = null,
|
||||
potOdds = if (ctx.toCall > 0) "${ctx.pot}:${ctx.toCall}" else "no bet to call",
|
||||
potOdds = if (ctx.toCall > 0) "${ctx.eligiblePot}:$affordableCall" else "no bet to call",
|
||||
intended = intended,
|
||||
chosen = chosen,
|
||||
mistakeApplied = mistakeApplied,
|
||||
@@ -204,7 +210,7 @@ class MathBot(
|
||||
pct <= raiseGate && ctx.canRaise ->
|
||||
Action(ActionType.RAISE, preflopRaiseTo(ctx, facingRaise))
|
||||
pct <= gate ->
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
|
||||
else ->
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
|
||||
}
|
||||
@@ -259,7 +265,7 @@ class MathBot(
|
||||
|
||||
// Sandbagging: under-represent a monster to keep them in.
|
||||
if (monster && random.nextDouble() < style.slowplayFrequency && ctx.street != Street.RIVER) {
|
||||
return if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||
return if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
|
||||
}
|
||||
|
||||
if (ctx.canCheck) {
|
||||
@@ -301,7 +307,7 @@ class MathBot(
|
||||
return Action(ActionType.RAISE, sizeBet(ctx, equity, aggression))
|
||||
}
|
||||
|
||||
return Action(ActionType.CALL, ctx.toCall)
|
||||
return Action(ActionType.CALL, ctx.callAmount)
|
||||
}
|
||||
|
||||
/** Pot-fraction sizing, widening with equity and aggression. */
|
||||
@@ -334,7 +340,7 @@ class MathBot(
|
||||
): Action = when (intended.type) {
|
||||
ActionType.FOLD ->
|
||||
if (ctx.toCall > 0 && percentile <= gate + PREFLOP_ERROR_BAND) {
|
||||
Action(ActionType.CALL, ctx.toCall)
|
||||
Action(ActionType.CALL, ctx.callAmount)
|
||||
} else {
|
||||
intended
|
||||
}
|
||||
@@ -345,7 +351,7 @@ class MathBot(
|
||||
intended
|
||||
}
|
||||
ActionType.BET, ActionType.RAISE ->
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
|
||||
ActionType.CHECK -> intended
|
||||
}
|
||||
|
||||
@@ -360,14 +366,14 @@ class MathBot(
|
||||
rawBreakEven: Double,
|
||||
): Action = when (intended.type) {
|
||||
ActionType.FOLD ->
|
||||
if (ctx.toCall > 0) Action(ActionType.CALL, ctx.toCall) else intended
|
||||
if (ctx.toCall > 0) Action(ActionType.CALL, ctx.callAmount) else intended
|
||||
ActionType.CALL -> when {
|
||||
equity >= rawBreakEven -> Action(ActionType.FOLD)
|
||||
ctx.canRaise -> Action(ActionType.RAISE, ctx.minRaiseTo)
|
||||
else -> intended
|
||||
}
|
||||
ActionType.BET, ActionType.RAISE ->
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.callAmount)
|
||||
ActionType.CHECK ->
|
||||
if (equity < 0.25 && ctx.canRaise) Action(ActionType.BET, ctx.minRaiseTo) else intended
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ data class DecisionOffer(
|
||||
val stack: Int,
|
||||
val canCheck: Boolean,
|
||||
val canRaise: Boolean,
|
||||
/** Public table state needed by deterministic post-action analysis. */
|
||||
val activeOpponents: Int = 1,
|
||||
val seatsActingAfter: Int = 0,
|
||||
val bigBlind: Int = 2,
|
||||
/** Current pot this seat can contest if it calls; excludes the call and higher side pots. */
|
||||
val eligiblePot: Int = pot,
|
||||
/** Highest amount committed by any seat in the current betting round. */
|
||||
val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0,
|
||||
) {
|
||||
/**
|
||||
* What calling actually costs.
|
||||
@@ -42,6 +50,8 @@ data class DecisionOffer(
|
||||
|
||||
/** True when calling would put this player all in. */
|
||||
val callIsAllIn: Boolean get() = toCall >= stack
|
||||
|
||||
val inPosition: Boolean get() = seatsActingAfter == 0
|
||||
}
|
||||
|
||||
fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer(
|
||||
@@ -58,4 +68,9 @@ fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer(
|
||||
stack = stack,
|
||||
canCheck = canCheck,
|
||||
canRaise = canRaise,
|
||||
activeOpponents = activeOpponents,
|
||||
seatsActingAfter = seatsActingAfter,
|
||||
bigBlind = bigBlind,
|
||||
eligiblePot = eligiblePot,
|
||||
currentBet = currentBet,
|
||||
)
|
||||
|
||||
@@ -77,10 +77,20 @@ class DecisionContext(
|
||||
val decisionToken: Long,
|
||||
/** TDA Rule 47: is this player facing at least a full raise since acting? */
|
||||
val bettingReopened: Boolean,
|
||||
/**
|
||||
* Portion of the current [pot] this seat can contest if it pays its affordable
|
||||
* call. The call itself is not included. Excludes unmatched excess and side
|
||||
* pots above this seat's final level.
|
||||
*/
|
||||
val eligiblePot: Int = pot,
|
||||
/** Highest amount committed by any seat in the current betting round. */
|
||||
val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0,
|
||||
) {
|
||||
val hole: IntArray get() = seat.hole
|
||||
val stack: Int get() = seat.stack
|
||||
val canCheck: Boolean get() = toCall == 0
|
||||
val callAmount: Int get() = minOf(toCall, stack)
|
||||
val callIsAllIn: Boolean get() = toCall >= stack
|
||||
|
||||
/**
|
||||
* True when this player may still put in a raise.
|
||||
@@ -444,6 +454,9 @@ class Table(
|
||||
private fun buildContext(street: Street, seat: Seat, toCall: Int): DecisionContext {
|
||||
val minRaiseTo = currentBet + minRaiseSize
|
||||
val maxRaiseTo = seat.committedThisRound + seat.stack
|
||||
val affordableCall = minOf(toCall, seat.stack)
|
||||
val finalContribution = seat.committedThisHand + affordableCall
|
||||
val eligiblePot = seats.sumOf { minOf(it.committedThisHand, finalContribution) }
|
||||
|
||||
var after = 0
|
||||
var i = (seat.index + 1) % seats.size
|
||||
@@ -468,6 +481,8 @@ class Table(
|
||||
handNumber = handNumber,
|
||||
decisionToken = pendingToken,
|
||||
bettingReopened = mayRaise(seat),
|
||||
eligiblePot = eligiblePot,
|
||||
currentBet = currentBet,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.jsjdesigns.poker.bot
|
||||
|
||||
import com.jsjdesigns.poker.core.cardsOf
|
||||
import com.jsjdesigns.poker.game.Action
|
||||
import com.jsjdesigns.poker.game.ActionType
|
||||
import com.jsjdesigns.poker.game.DecisionOffer
|
||||
import com.jsjdesigns.poker.game.Street
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DecisionCoachTest {
|
||||
|
||||
private fun offer(
|
||||
street: Street = Street.RIVER,
|
||||
hole: String = "Ah Qh",
|
||||
board: String = "2c 7d 9s Jc 3h",
|
||||
pot: Int = 80,
|
||||
toCall: Int = 20,
|
||||
canCheck: Boolean = false,
|
||||
canRaise: Boolean = true,
|
||||
activeOpponents: Int = 1,
|
||||
stack: Int = 200,
|
||||
eligiblePot: Int = pot,
|
||||
currentBet: Int = if (street == Street.PREFLOP) 2 else 0,
|
||||
) = DecisionOffer(
|
||||
token = 91,
|
||||
handNumber = 7,
|
||||
street = street,
|
||||
seat = 0,
|
||||
hole = cardsOf(hole).toList(),
|
||||
board = cardsOf(board).toList(),
|
||||
pot = pot,
|
||||
toCall = toCall,
|
||||
minRaiseTo = 60,
|
||||
maxRaiseTo = 200,
|
||||
stack = stack,
|
||||
canCheck = canCheck,
|
||||
canRaise = canRaise,
|
||||
activeOpponents = activeOpponents,
|
||||
seatsActingAfter = 1,
|
||||
bigBlind = 2,
|
||||
eligiblePot = eligiblePot,
|
||||
currentBet = currentBet,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `same public decision always produces the same review`() {
|
||||
val coach = DecisionCoach(equityIterations = 600)
|
||||
val decision = offer()
|
||||
val chosen = Action(ActionType.FOLD)
|
||||
|
||||
assertEquals(coach.review(decision, chosen), coach.review(decision, chosen))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `postflop trace is honest about equity and raw pot odds`() {
|
||||
val review = DecisionCoach(equityIterations = 800).review(
|
||||
offer(pot = 80, toCall = 20),
|
||||
Action(ActionType.CALL, 20),
|
||||
)
|
||||
val trace = review.trace
|
||||
|
||||
assertNotNull(trace.estimatedEquity)
|
||||
assertEquals(0.20, trace.breakEvenEquity!!, 1e-12)
|
||||
assertEquals(trace.breakEvenEquity, trace.decisionThreshold)
|
||||
assertEquals("80:20", trace.potOdds)
|
||||
assertNull(trace.handStrengthPercentile)
|
||||
assertTrue("unknown random hand" in trace.reason)
|
||||
assertFalse(trace.mistakeApplied, "human disagreement is not a bot skill error")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preflop review uses chart fields and never fabricates equity`() {
|
||||
val review = DecisionCoach().review(
|
||||
offer(street = Street.PREFLOP, hole = "As Ad", board = ""),
|
||||
Action(ActionType.RAISE, 6),
|
||||
)
|
||||
val trace = review.trace
|
||||
|
||||
assertNull(trace.estimatedEquity)
|
||||
assertNotNull(trace.handStrengthPercentile)
|
||||
assertNotNull(trace.preflopRangeThreshold)
|
||||
assertNull(trace.breakEvenEquity)
|
||||
assertNull(trace.decisionThreshold)
|
||||
assertNull(trace.potOdds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `big blind option is described as a raise rather than a bet`() {
|
||||
val review = DecisionCoach().review(
|
||||
offer(
|
||||
street = Street.PREFLOP,
|
||||
hole = "As Ad",
|
||||
board = "",
|
||||
toCall = 0,
|
||||
canCheck = true,
|
||||
),
|
||||
Action(ActionType.RAISE, 60),
|
||||
)
|
||||
|
||||
assertEquals(ActionType.RAISE, review.trace.intended.type)
|
||||
assertTrue(review.alignedWithBaseline)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preflop raise is identified by table bet level rather than amount owed`() {
|
||||
val coach = DecisionCoach()
|
||||
val unopened = coach.review(
|
||||
offer(street = Street.PREFLOP, board = "", toCall = 2, currentBet = 2),
|
||||
Action(ActionType.FOLD),
|
||||
)
|
||||
val facingMinimumRaise = coach.review(
|
||||
// The big blind has 2 invested, so a raise to 4 leaves only 2 owed.
|
||||
offer(street = Street.PREFLOP, board = "", toCall = 2, currentBet = 4),
|
||||
Action(ActionType.FOLD),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
unopened.trace.preflopRangeThreshold!! * 0.45,
|
||||
facingMinimumRaise.trace.preflopRangeThreshold!!,
|
||||
1e-12,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bet and raise spellings are the same aggressive action family`() {
|
||||
val review = DecisionCoach(equityIterations = 300).review(
|
||||
offer(
|
||||
hole = "As Ad",
|
||||
board = "Ah 7d 9s Jc 3h",
|
||||
toCall = 0,
|
||||
canCheck = true,
|
||||
),
|
||||
// The app sends RAISE; Table sanitises it to BET when no bet exists.
|
||||
Action(ActionType.RAISE, 60),
|
||||
)
|
||||
|
||||
assertEquals(ActionType.BET, review.trace.intended.type)
|
||||
assertTrue(review.alignedWithBaseline)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short all-in odds exclude chips in side pots the hero cannot win`() {
|
||||
val review = DecisionCoach(equityIterations = 300).review(
|
||||
offer(
|
||||
pot = 120,
|
||||
eligiblePot = 40,
|
||||
toCall = 100,
|
||||
stack = 20,
|
||||
canRaise = false,
|
||||
),
|
||||
Action(ActionType.CALL, 20),
|
||||
)
|
||||
|
||||
assertEquals(1.0 / 3.0, review.trace.breakEvenEquity!!, 1e-12)
|
||||
assertEquals("40:20", review.trace.potOdds)
|
||||
}
|
||||
}
|
||||
@@ -120,4 +120,38 @@ class DecisionTraceTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bot pot odds exclude inaccessible side-pot chips`() = runTest {
|
||||
val bot = MathBot(
|
||||
BotProfile("E", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
|
||||
Random(19),
|
||||
)
|
||||
val seat = Seat(0, "Expert", 20, bot)
|
||||
seat.hole = cardsOf("As Ad")
|
||||
val ctx = DecisionContext(
|
||||
street = Street.RIVER,
|
||||
seat = seat,
|
||||
board = cardsOf("Ah 7d 9s Jc 3h"),
|
||||
pot = 120,
|
||||
toCall = 100,
|
||||
minRaiseTo = 220,
|
||||
maxRaiseTo = 20,
|
||||
activeOpponents = 2,
|
||||
seatsActingAfter = 1,
|
||||
bigBlind = 2,
|
||||
history = emptyList(),
|
||||
handNumber = 1,
|
||||
decisionToken = 1,
|
||||
bettingReopened = false,
|
||||
eligiblePot = 40,
|
||||
)
|
||||
|
||||
bot.act(ctx)
|
||||
|
||||
assertEquals(1.0 / 3.0, bot.lastTrace!!.breakEvenEquity!!, 1e-12)
|
||||
assertEquals("40:20", bot.lastTrace!!.potOdds)
|
||||
assertEquals(com.jsjdesigns.poker.game.ActionType.CALL, bot.lastTrace!!.intended.type)
|
||||
assertEquals(20, bot.lastTrace!!.intended.amount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ private data class Offer(
|
||||
val canRaise: Boolean,
|
||||
val canCheck: Boolean,
|
||||
val pot: Int,
|
||||
val eligiblePot: Int,
|
||||
val minRaiseTo: Int,
|
||||
val maxRaiseTo: Int,
|
||||
)
|
||||
@@ -26,7 +27,7 @@ private class Scripted(private vararg val actions: Action) : PlayerAgent {
|
||||
override suspend fun act(ctx: DecisionContext): Action {
|
||||
offers += Offer(
|
||||
ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck,
|
||||
ctx.pot, ctx.minRaiseTo, ctx.maxRaiseTo,
|
||||
ctx.pot, ctx.eligiblePot, ctx.minRaiseTo, ctx.maxRaiseTo,
|
||||
)
|
||||
return actions.getOrElse(i++) {
|
||||
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
|
||||
@@ -183,6 +184,32 @@ class TableRulesTest {
|
||||
assertTrue(result.net[2] < 0, "queens should lose")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decision context excludes inaccessible side-pot chips from pot odds`() = runTest {
|
||||
val short = Scripted(Action(ActionType.CALL, 20), Action(ActionType.FOLD))
|
||||
val raiser = Scripted(Action(ActionType.RAISE, 100))
|
||||
val caller = Scripted(Action(ActionType.CALL, 80))
|
||||
val seats = listOf(
|
||||
Seat(0, "Short", 50, short),
|
||||
Seat(1, "Raiser", 200, raiser),
|
||||
Seat(2, "Caller", 200, caller),
|
||||
)
|
||||
|
||||
Table(
|
||||
seats, 10, 20, Random(1),
|
||||
StackedDeck.of(listOf("Ah Ad", "Kh Kd", "Qh Qd"), "2c 7d 9s Jc 3h"),
|
||||
).playHand()
|
||||
|
||||
val facingRaise = short.offers[1]
|
||||
assertEquals(220, facingRaise.pot, "the visible table pot includes the higher side pot")
|
||||
assertEquals(
|
||||
120,
|
||||
facingRaise.eligiblePot,
|
||||
"short stack may contest only 50 from each opponent plus its 20 already committed",
|
||||
)
|
||||
assertEquals(80, facingRaise.toCall)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user