Android target, suspending agents, and observable table snapshots
Build: - :engine now uses com.android.kotlin.multiplatform.library (AGP 9.2.1), the modern KMP Android integration, rather than plain androidTarget(). Produces engine.aar alongside the JVM target; compileAndroidMain verified. - Version catalog added; SDK levels match the other JSJ apps (compileSdk 37, minSdk 26). Engine: - PlayerAgent.act() and Table.playHand() are now suspend, so a human player can wait for input without blocking a thread. Bots are unaffected; the simulator wraps in runBlocking. - TableSnapshot/SeatSnapshot published after the deal, before and after every action, and at the finish. Immutable, aliasing no live Seat state, giving animation, hand history, saving, and replay one boundary to work against. - Snapshots carry the whole truth; maskedFor(viewer) is an explicit step that hides hole cards the viewer is not entitled to. Showdown reveals contenders; folded hands never are. - Terminal snapshots report the contested pot rather than 0. settle() zeroes contributions when awarding, so the naive value was empty at exactly the moment the UI needs to show what was won. Caught by a new test. - HumanAgent suspends on a CompletableDeferred and clears its pending state in a finally block, so cancelling an abandoned hand releases the wait instead of stranding it. Re-usable afterwards; a stale submit returns false. Tests: 37 -> 45. Chips still conserved, skill gradient still monotonic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+6
-2
@@ -1,4 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform") version "2.2.10" apply false
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
kotlin("jvm") version "2.2.10" apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -1,15 +1,26 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform")
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// JVM target drives tests and the headless simulator today.
|
// JVM target drives tests and the headless tuning simulator.
|
||||||
// androidTarget() / iosArm64() slot in here later without touching commonMain.
|
|
||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
|
// The modern KMP Android integration. `androidTarget()` is the older path.
|
||||||
|
androidLibrary {
|
||||||
|
namespace = "com.jsjdesigns.poker.engine"
|
||||||
|
compileSdk = 37
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class MathBot(
|
|||||||
var lastTrace: DecisionTrace? = null
|
var lastTrace: DecisionTrace? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
override fun act(ctx: DecisionContext): Action {
|
override suspend fun act(ctx: DecisionContext): Action {
|
||||||
val skill = profile.skill
|
val skill = profile.skill
|
||||||
val style = profile.style
|
val style = profile.style
|
||||||
val opponents = ctx.activeOpponents.coerceAtLeast(1)
|
val opponents = ctx.activeOpponents.coerceAtLeast(1)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.jsjdesigns.poker.game
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [PlayerAgent] driven by the UI rather than by code.
|
||||||
|
*
|
||||||
|
* When it is this player's turn, [act] publishes the decision on offer and then
|
||||||
|
* suspends until [submit] delivers a choice. Cancelling the coroutine running the
|
||||||
|
* hand — a screen closing, a game being abandoned — completes the pending wait
|
||||||
|
* exceptionally rather than leaving it parked forever.
|
||||||
|
*/
|
||||||
|
class HumanAgent : PlayerAgent {
|
||||||
|
|
||||||
|
private val lock = Mutex()
|
||||||
|
private var pending: CompletableDeferred<Action>? = null
|
||||||
|
|
||||||
|
/** The decision currently awaiting input, or null when it is not our turn. */
|
||||||
|
var awaiting: DecisionContext? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override suspend fun act(ctx: DecisionContext): Action {
|
||||||
|
val deferred = CompletableDeferred<Action>()
|
||||||
|
lock.withLock {
|
||||||
|
check(pending == null) { "already awaiting a decision for this agent" }
|
||||||
|
pending = deferred
|
||||||
|
awaiting = ctx
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
deferred.await()
|
||||||
|
} finally {
|
||||||
|
// Runs on normal completion AND on cancellation, so the agent is never
|
||||||
|
// left believing it is still waiting.
|
||||||
|
lock.withLock {
|
||||||
|
pending = null
|
||||||
|
awaiting = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies the player's choice. Returns false when nothing was waiting, which
|
||||||
|
* makes a double-tap or a stale click harmless rather than a crash.
|
||||||
|
*/
|
||||||
|
suspend fun submit(action: Action): Boolean = lock.withLock {
|
||||||
|
val deferred = pending ?: return@withLock false
|
||||||
|
deferred.complete(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abandons the pending decision, releasing [act] with a cancellation. */
|
||||||
|
suspend fun cancel(cause: Throwable = IllegalStateException("hand abandoned")) {
|
||||||
|
lock.withLock { pending?.completeExceptionally(cause) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val isAwaitingInput: Boolean get() = awaiting != null
|
||||||
|
}
|
||||||
@@ -86,8 +86,13 @@ class DecisionContext(
|
|||||||
val inPosition: Boolean get() = seatsActingAfter == 0
|
val inPosition: Boolean get() = seatsActingAfter == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspending because a human player has to wait for a tap. Bots return
|
||||||
|
* immediately and are unaffected; the simulator wraps the whole thing in
|
||||||
|
* `runBlocking`.
|
||||||
|
*/
|
||||||
fun interface PlayerAgent {
|
fun interface PlayerAgent {
|
||||||
fun act(ctx: DecisionContext): Action
|
suspend fun act(ctx: DecisionContext): Action
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Pot(val amount: Int, val eligible: List<Int>)
|
data class Pot(val amount: Int, val eligible: List<Int>)
|
||||||
@@ -114,6 +119,11 @@ class Table(
|
|||||||
val bigBlind: Int,
|
val bigBlind: Int,
|
||||||
private val random: Random = Random.Default,
|
private val random: Random = Random.Default,
|
||||||
private val deck: CardSource = Deck(random),
|
private val deck: CardSource = Deck(random),
|
||||||
|
/**
|
||||||
|
* Called after the deal and after every action. Defaults to a no-op so the
|
||||||
|
* simulator pays nothing for it.
|
||||||
|
*/
|
||||||
|
private val observer: suspend (TableSnapshot) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var button: Int = 0
|
var button: Int = 0
|
||||||
private set
|
private set
|
||||||
@@ -127,6 +137,46 @@ class Table(
|
|||||||
|
|
||||||
private var currentBet = 0
|
private var currentBet = 0
|
||||||
private var minRaiseSize = 0
|
private var minRaiseSize = 0
|
||||||
|
private var currentStreet = Street.PREFLOP
|
||||||
|
private val revealed = HashSet<Int>()
|
||||||
|
|
||||||
|
private fun snapshot(phase: TableSnapshot.Phase, toAct: Int?, potOverride: Int?) = TableSnapshot(
|
||||||
|
handNumber = handNumber,
|
||||||
|
street = currentStreet,
|
||||||
|
phase = phase,
|
||||||
|
board = board.toList(),
|
||||||
|
// settle() zeroes contributions once the pot is awarded, so a terminal
|
||||||
|
// snapshot has to report the pot that was contested rather than 0 — that
|
||||||
|
// is the number the UI needs to show at exactly that moment.
|
||||||
|
pot = potOverride ?: pot(),
|
||||||
|
currentBet = currentBet,
|
||||||
|
minRaiseSize = minRaiseSize,
|
||||||
|
button = button,
|
||||||
|
seats = seats.map { s ->
|
||||||
|
SeatSnapshot(
|
||||||
|
index = s.index,
|
||||||
|
name = s.name,
|
||||||
|
stack = s.stack,
|
||||||
|
committedThisRound = s.committedThisRound,
|
||||||
|
committedThisHand = s.committedThisHand,
|
||||||
|
folded = s.folded,
|
||||||
|
allIn = s.allIn,
|
||||||
|
hole = if (s.hole.isEmpty()) null else s.hole.toList(),
|
||||||
|
revealed = s.index in revealed,
|
||||||
|
isButton = s.index == button,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
toAct = toAct,
|
||||||
|
lastAction = events.lastOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun emit(
|
||||||
|
phase: TableSnapshot.Phase,
|
||||||
|
toAct: Int? = null,
|
||||||
|
potOverride: Int? = null,
|
||||||
|
) {
|
||||||
|
observer(snapshot(phase, toAct, potOverride))
|
||||||
|
}
|
||||||
|
|
||||||
fun advanceButton() {
|
fun advanceButton() {
|
||||||
button = nextOccupied(button)
|
button = nextOccupied(button)
|
||||||
@@ -146,7 +196,7 @@ class Table(
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playHand(): HandResult {
|
suspend fun playHand(): HandResult {
|
||||||
handNumber++
|
handNumber++
|
||||||
val startingStacks = IntArray(seats.size) { seats[it].stack }
|
val startingStacks = IntArray(seats.size) { seats[it].stack }
|
||||||
resetForHand()
|
resetForHand()
|
||||||
@@ -156,11 +206,14 @@ class Table(
|
|||||||
|
|
||||||
postBlinds()
|
postBlinds()
|
||||||
dealHoleCards()
|
dealHoleCards()
|
||||||
|
currentStreet = Street.PREFLOP
|
||||||
|
emit(TableSnapshot.Phase.DEALT)
|
||||||
|
|
||||||
var street = Street.PREFLOP
|
var street = Street.PREFLOP
|
||||||
var finished = false
|
var finished = false
|
||||||
|
|
||||||
while (!finished) {
|
while (!finished) {
|
||||||
|
currentStreet = street
|
||||||
val first = firstToAct(street)
|
val first = firstToAct(street)
|
||||||
runBettingRound(street, first)
|
runBettingRound(street, first)
|
||||||
|
|
||||||
@@ -185,15 +238,23 @@ class Table(
|
|||||||
Street.TURN -> { dealRiver(); Street.RIVER }
|
Street.TURN -> { dealRiver(); Street.RIVER }
|
||||||
Street.RIVER -> { finished = true; Street.RIVER }
|
Street.RIVER -> { finished = true; Street.RIVER }
|
||||||
}
|
}
|
||||||
|
currentStreet = street
|
||||||
|
emit(TableSnapshot.Phase.STREET_COMPLETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
return settle(startingStacks)
|
val result = settle(startingStacks)
|
||||||
|
emit(
|
||||||
|
if (result.wentToShowdown) TableSnapshot.Phase.SHOWDOWN else TableSnapshot.Phase.COMPLETE,
|
||||||
|
potOverride = result.potSize,
|
||||||
|
)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetForHand() {
|
private fun resetForHand() {
|
||||||
deck.shuffle()
|
deck.shuffle()
|
||||||
board.clear()
|
board.clear()
|
||||||
events.clear()
|
events.clear()
|
||||||
|
revealed.clear()
|
||||||
currentBet = 0
|
currentBet = 0
|
||||||
minRaiseSize = bigBlind
|
minRaiseSize = bigBlind
|
||||||
for (s in seats) {
|
for (s in seats) {
|
||||||
@@ -285,7 +346,7 @@ class Table(
|
|||||||
private fun mayRaise(seat: Seat): Boolean =
|
private fun mayRaise(seat: Seat): Boolean =
|
||||||
seat.lastActedAtBet < 0 || (currentBet - seat.lastActedAtBet) >= minRaiseSize
|
seat.lastActedAtBet < 0 || (currentBet - seat.lastActedAtBet) >= minRaiseSize
|
||||||
|
|
||||||
private fun runBettingRound(street: Street, firstSeat: Int) {
|
private suspend fun runBettingRound(street: Street, firstSeat: Int) {
|
||||||
for (s in seats) {
|
for (s in seats) {
|
||||||
s.committedThisRound = 0
|
s.committedThisRound = 0
|
||||||
s.hasActed = false
|
s.hasActed = false
|
||||||
@@ -318,12 +379,15 @@ class Table(
|
|||||||
val seat = seats[i]
|
val seat = seats[i]
|
||||||
if (seat.canAct && (!seat.hasActed || seat.committedThisRound < currentBet)) {
|
if (seat.canAct && (!seat.hasActed || seat.committedThisRound < currentBet)) {
|
||||||
val toCall = (currentBet - seat.committedThisRound).coerceAtLeast(0)
|
val toCall = (currentBet - seat.committedThisRound).coerceAtLeast(0)
|
||||||
|
// Publish before asking, so the UI can show whose turn it is.
|
||||||
|
emit(TableSnapshot.Phase.BETTING, toAct = seat.index)
|
||||||
val ctx = buildContext(street, seat, toCall)
|
val ctx = buildContext(street, seat, toCall)
|
||||||
val action = sanitise(seat, toCall, seat.agent.act(ctx))
|
val action = sanitise(seat, toCall, seat.agent.act(ctx))
|
||||||
apply(street, seat, action, toCall)
|
apply(street, seat, action, toCall)
|
||||||
seat.hasActed = true
|
seat.hasActed = true
|
||||||
// Record the level they acted at — after their own raise, if any.
|
// Record the level they acted at — after their own raise, if any.
|
||||||
seat.lastActedAtBet = currentBet
|
seat.lastActedAtBet = currentBet
|
||||||
|
emit(TableSnapshot.Phase.BETTING)
|
||||||
|
|
||||||
if (seats.count { it.contesting } <= 1) return
|
if (seats.count { it.contesting } <= 1) return
|
||||||
}
|
}
|
||||||
@@ -437,6 +501,8 @@ class Table(
|
|||||||
winners.add(w.index)
|
winners.add(w.index)
|
||||||
} else {
|
} else {
|
||||||
wentToShowdown = true
|
wentToShowdown = true
|
||||||
|
// Cards are face up from here, so snapshots may show them.
|
||||||
|
revealed.addAll(contenders.map { it.index })
|
||||||
val scores = HashMap<Int, Int>()
|
val scores = HashMap<Int, Int>()
|
||||||
for (c in contenders) {
|
for (c in contenders) {
|
||||||
val seven = IntArray(7)
|
val seven = IntArray(7)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.jsjdesigns.poker.game
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a hand looks like at one instant.
|
||||||
|
*
|
||||||
|
* The engine is otherwise a black box that runs a whole hand in one call, which
|
||||||
|
* gives a UI nothing to render between actions. A snapshot is published after the
|
||||||
|
* deal and after every action so the UI can animate, and so hand history, saving,
|
||||||
|
* and replay all have one boundary to work against.
|
||||||
|
*
|
||||||
|
* Immutable by construction: nothing here aliases live [Seat] state.
|
||||||
|
*/
|
||||||
|
data class TableSnapshot(
|
||||||
|
val handNumber: Int,
|
||||||
|
val street: Street,
|
||||||
|
val phase: Phase,
|
||||||
|
val board: List<Int>,
|
||||||
|
val pot: Int,
|
||||||
|
val currentBet: Int,
|
||||||
|
val minRaiseSize: Int,
|
||||||
|
val button: Int,
|
||||||
|
val seats: List<SeatSnapshot>,
|
||||||
|
/** Seat currently owed an action, or null between streets and at showdown. */
|
||||||
|
val toAct: Int?,
|
||||||
|
val lastAction: HandEvent?,
|
||||||
|
) {
|
||||||
|
enum class Phase { DEALT, BETTING, STREET_COMPLETE, SHOWDOWN, COMPLETE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides hole cards this viewer is not entitled to see.
|
||||||
|
*
|
||||||
|
* The engine publishes the whole truth — replay and debugging need it — so
|
||||||
|
* masking is an explicit step rather than something the UI is trusted to
|
||||||
|
* remember. Cards already revealed at showdown stay visible.
|
||||||
|
*/
|
||||||
|
fun maskedFor(viewer: Int): TableSnapshot = copy(
|
||||||
|
seats = seats.map {
|
||||||
|
if (it.index == viewer || it.revealed) it else it.copy(hole = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val activeSeats: List<SeatSnapshot> get() = seats.filter { !it.folded }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SeatSnapshot(
|
||||||
|
val index: Int,
|
||||||
|
val name: String,
|
||||||
|
val stack: Int,
|
||||||
|
val committedThisRound: Int,
|
||||||
|
val committedThisHand: Int,
|
||||||
|
val folded: Boolean,
|
||||||
|
val allIn: Boolean,
|
||||||
|
/** Null once masked, or before cards are dealt. */
|
||||||
|
val hole: List<Int>?,
|
||||||
|
/** True once this hand has been turned face up at showdown. */
|
||||||
|
val revealed: Boolean,
|
||||||
|
val isButton: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package com.jsjdesigns.poker.game
|
||||||
|
|
||||||
|
import com.jsjdesigns.poker.core.StackedDeck
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private class Folder : PlayerAgent {
|
||||||
|
override suspend fun act(ctx: DecisionContext): Action =
|
||||||
|
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Caller : PlayerAgent {
|
||||||
|
override suspend fun act(ctx: DecisionContext): Action =
|
||||||
|
if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens once so that someone downstream actually has a bet to fold to. */
|
||||||
|
private class Raiser(private val to: Int) : PlayerAgent {
|
||||||
|
private var raised = false
|
||||||
|
override suspend fun act(ctx: DecisionContext): Action = when {
|
||||||
|
!raised && ctx.canRaise -> { raised = true; Action(ActionType.RAISE, to) }
|
||||||
|
ctx.canCheck -> Action(ActionType.CHECK)
|
||||||
|
else -> Action(ActionType.CALL, ctx.toCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnapshotAndHumanAgentTest {
|
||||||
|
|
||||||
|
private fun context(seat: Seat) = DecisionContext(
|
||||||
|
street = Street.PREFLOP,
|
||||||
|
seat = seat,
|
||||||
|
board = IntArray(0),
|
||||||
|
pot = 15,
|
||||||
|
toCall = 10,
|
||||||
|
minRaiseTo = 20,
|
||||||
|
maxRaiseTo = 500,
|
||||||
|
activeOpponents = 1,
|
||||||
|
seatsActingAfter = 0,
|
||||||
|
bigBlind = 10,
|
||||||
|
history = emptyList(),
|
||||||
|
handNumber = 1,
|
||||||
|
bettingReopened = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- snapshots ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snapshots are published for the deal, every action, and the finish`() = runTest {
|
||||||
|
val seen = mutableListOf<TableSnapshot>()
|
||||||
|
val seats = listOf(
|
||||||
|
Seat(0, "A", 500, Caller()),
|
||||||
|
Seat(1, "B", 500, Caller()),
|
||||||
|
)
|
||||||
|
Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
observer = { seen += it },
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
assertTrue(seen.isNotEmpty(), "the UI must receive something to render")
|
||||||
|
assertEquals(TableSnapshot.Phase.DEALT, seen.first().phase, "first snapshot is the deal")
|
||||||
|
assertTrue(
|
||||||
|
seen.any { it.phase == TableSnapshot.Phase.BETTING && it.toAct != null },
|
||||||
|
"some snapshot must name whose turn it is",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
seen.last().phase == TableSnapshot.Phase.SHOWDOWN ||
|
||||||
|
seen.last().phase == TableSnapshot.Phase.COMPLETE,
|
||||||
|
"the hand must end on a terminal phase, was ${seen.last().phase}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a snapshot carries the board and pot as the hand progresses`() = runTest {
|
||||||
|
val seen = mutableListOf<TableSnapshot>()
|
||||||
|
val seats = listOf(Seat(0, "A", 500, Caller()), Seat(1, "B", 500, Caller()))
|
||||||
|
Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
observer = { seen += it },
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
assertEquals(0, seen.first().board.size, "no board on the deal")
|
||||||
|
assertEquals(5, seen.last().board.size, "full board by the end")
|
||||||
|
assertTrue(seen.last().pot > 0)
|
||||||
|
assertTrue(seen.all { it.handNumber == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `masking hides other players hole cards until showdown`() = runTest {
|
||||||
|
val seen = mutableListOf<TableSnapshot>()
|
||||||
|
val seats = listOf(Seat(0, "A", 500, Caller()), Seat(1, "B", 500, Caller()))
|
||||||
|
Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
observer = { seen += it },
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
val duringPlay = seen.first { it.phase == TableSnapshot.Phase.DEALT }.maskedFor(viewer = 0)
|
||||||
|
assertNotNull(duringPlay.seats[0].hole, "a player always sees their own cards")
|
||||||
|
assertNull(duringPlay.seats[1].hole, "an opponent's cards must be hidden")
|
||||||
|
|
||||||
|
// Both called to the river, so both are revealed at showdown.
|
||||||
|
val end = seen.last().maskedFor(viewer = 0)
|
||||||
|
assertNotNull(end.seats[1].hole, "cards turned up at showdown stay visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `folded hands are never revealed`() = runTest {
|
||||||
|
val seen = mutableListOf<TableSnapshot>()
|
||||||
|
// The raiser gives the folder something to fold to; a checker faces nothing
|
||||||
|
// heads-up, and sanitise() turns a pointless FOLD into a CHECK.
|
||||||
|
val seats = listOf(Seat(0, "A", 500, Raiser(to = 40)), Seat(1, "B", 500, Folder()))
|
||||||
|
val result = Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
observer = { seen += it },
|
||||||
|
).playHand()
|
||||||
|
|
||||||
|
assertFalse(result.wentToShowdown, "the hand must actually end in a fold")
|
||||||
|
val end = seen.last().maskedFor(viewer = 0)
|
||||||
|
val folded = end.seats.first { it.folded }
|
||||||
|
assertNull(folded.hole, "a folded hand must stay face down")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- human input ----------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit resolves the pending decision`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
val seat = Seat(0, "You", 500, human)
|
||||||
|
val decision = async { human.act(context(seat)) }
|
||||||
|
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
assertNotNull(human.awaiting, "the UI must be able to see what is on offer")
|
||||||
|
assertTrue(human.submit(Action(ActionType.CALL, 10)))
|
||||||
|
|
||||||
|
assertEquals(ActionType.CALL, decision.await().type)
|
||||||
|
assertFalse(human.isAwaitingInput, "the wait must clear once answered")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submitting when nothing is pending is harmless`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
assertFalse(human.submit(Action(ActionType.FOLD)), "a stale tap must not crash")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cancelling the hand releases a human waiting for input`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
val seat = Seat(0, "You", 500, human)
|
||||||
|
val job = launch { human.act(context(seat)) }
|
||||||
|
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
job.cancel()
|
||||||
|
job.join()
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
human.isAwaitingInput,
|
||||||
|
"cancellation must clear the pending wait, not strand it forever",
|
||||||
|
)
|
||||||
|
// The agent is reusable afterwards.
|
||||||
|
val next = async { human.act(context(seat)) }
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
assertTrue(human.submit(Action(ActionType.FOLD)))
|
||||||
|
assertEquals(ActionType.FOLD, next.await().type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a human can play a full hand against a bot`() = runTest {
|
||||||
|
val human = HumanAgent()
|
||||||
|
val seen = mutableListOf<TableSnapshot>()
|
||||||
|
val seats = listOf(Seat(0, "You", 500, human), Seat(1, "Bot", 500, Caller()))
|
||||||
|
val table = Table(
|
||||||
|
seats, 5, 10, Random(1),
|
||||||
|
StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
|
||||||
|
observer = { seen += it },
|
||||||
|
)
|
||||||
|
|
||||||
|
val hand = async { table.playHand() }
|
||||||
|
// Fold at the first opportunity.
|
||||||
|
while (!human.isAwaitingInput) yield()
|
||||||
|
human.submit(Action(ActionType.FOLD))
|
||||||
|
|
||||||
|
val result = hand.await()
|
||||||
|
assertEquals(0, result.net.sum(), "chips conserved through a human-driven hand")
|
||||||
|
assertTrue(seen.isNotEmpty())
|
||||||
|
assertEquals(listOf(1), result.winners, "the bot wins once the human folds")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.jsjdesigns.poker.game
|
package com.jsjdesigns.poker.game
|
||||||
|
|
||||||
import com.jsjdesigns.poker.core.StackedDeck
|
import com.jsjdesigns.poker.core.StackedDeck
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -22,7 +23,7 @@ private class Scripted(private vararg val actions: Action) : PlayerAgent {
|
|||||||
private var i = 0
|
private var i = 0
|
||||||
val offers = mutableListOf<Offer>()
|
val offers = mutableListOf<Offer>()
|
||||||
|
|
||||||
override fun act(ctx: DecisionContext): Action {
|
override suspend fun act(ctx: DecisionContext): Action {
|
||||||
offers += Offer(
|
offers += Offer(
|
||||||
ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck,
|
ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck,
|
||||||
ctx.pot, ctx.minRaiseTo, ctx.maxRaiseTo,
|
ctx.pot, ctx.minRaiseTo, ctx.maxRaiseTo,
|
||||||
@@ -34,7 +35,7 @@ private class Scripted(private vararg val actions: Action) : PlayerAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class RandomAgent(private val random: Random) : PlayerAgent {
|
private class RandomAgent(private val random: Random) : PlayerAgent {
|
||||||
override fun act(ctx: DecisionContext): Action = when (random.nextInt(5)) {
|
override suspend fun act(ctx: DecisionContext): Action = when (random.nextInt(5)) {
|
||||||
0 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
|
0 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.FOLD)
|
||||||
1, 2 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
1, 2 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall)
|
||||||
else -> Action(ActionType.RAISE, ctx.minRaiseTo + random.nextInt(50))
|
else -> Action(ActionType.RAISE, ctx.minRaiseTo + random.nextInt(50))
|
||||||
@@ -46,7 +47,7 @@ class TableRulesTest {
|
|||||||
// ---------- incomplete (short all-in) raises ----------
|
// ---------- incomplete (short all-in) raises ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `short all-in does not reopen betting for a player who already acted`() {
|
fun `short all-in does not reopen betting for a player who already acted`() = runTest {
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 30))
|
val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 30))
|
||||||
val p1 = Scripted(Action(ActionType.RAISE, 130)) // all-in, only a 30 raise
|
val p1 = Scripted(Action(ActionType.RAISE, 130)) // all-in, only a 30 raise
|
||||||
val p2 = Scripted(Action(ActionType.FOLD))
|
val p2 = Scripted(Action(ActionType.FOLD))
|
||||||
@@ -69,7 +70,7 @@ class TableRulesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a full raise does reopen betting`() {
|
fun `a full raise does reopen betting`() = runTest {
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.FOLD))
|
val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.FOLD))
|
||||||
val p1 = Scripted(Action(ActionType.RAISE, 300)) // full re-raise
|
val p1 = Scripted(Action(ActionType.RAISE, 300)) // full re-raise
|
||||||
val p2 = Scripted(Action(ActionType.FOLD))
|
val p2 = Scripted(Action(ActionType.FOLD))
|
||||||
@@ -90,7 +91,7 @@ class TableRulesTest {
|
|||||||
* DO reopen the betting, even though no single one of them would.
|
* DO reopen the betting, even though no single one of them would.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `cumulative short all-ins reopen betting once they total a full raise`() {
|
fun `cumulative short all-ins reopen betting once they total a full raise`() = runTest {
|
||||||
// P3 opens to 100 (min raise size becomes 80). Two short all-ins follow,
|
// P3 opens to 100 (min raise size becomes 80). Two short all-ins follow,
|
||||||
// 40 each: neither reopens alone, but together they reach 80.
|
// 40 each: neither reopens alone, but together they reach 80.
|
||||||
val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 80))
|
val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 80))
|
||||||
@@ -116,7 +117,7 @@ class TableRulesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cumulative short all-ins below a full raise still do not reopen`() {
|
fun `cumulative short all-ins below a full raise still do not reopen`() = runTest {
|
||||||
// Same shape, but the shorts total only 40 against a min raise of 80.
|
// 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 p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 40))
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 120)) // all-in, +20
|
val p0 = Scripted(Action(ActionType.RAISE, 120)) // all-in, +20
|
||||||
@@ -137,7 +138,7 @@ class TableRulesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an illegal raise attempt is downgraded to a call`() {
|
fun `an illegal raise attempt is downgraded to a call`() = runTest {
|
||||||
// P0 tries to re-raise after only an incomplete all-in; engine must clamp it.
|
// P0 tries to re-raise after only an incomplete all-in; engine must clamp it.
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.RAISE, 500))
|
val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.RAISE, 500))
|
||||||
val p1 = Scripted(Action(ActionType.RAISE, 130))
|
val p1 = Scripted(Action(ActionType.RAISE, 130))
|
||||||
@@ -158,7 +159,7 @@ class TableRulesTest {
|
|||||||
// ---------- side pots ----------
|
// ---------- side pots ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `side pots pay the short stack from the main pot only`() {
|
fun `side pots pay the short stack from the main pot only`() = runTest {
|
||||||
// P0 all-in for 50 with aces, P1 and P2 fight for the rest.
|
// P0 all-in for 50 with aces, P1 and P2 fight for the rest.
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 50))
|
val p0 = Scripted(Action(ActionType.RAISE, 50))
|
||||||
val p1 = Scripted(Action(ActionType.CALL, 40), Action(ActionType.RAISE, 150), Action(ActionType.CHECK))
|
val p1 = Scripted(Action(ActionType.CALL, 40), Action(ActionType.RAISE, 150), Action(ActionType.CHECK))
|
||||||
@@ -188,7 +189,7 @@ class TableRulesTest {
|
|||||||
* winning seat left of the button.
|
* winning seat left of the button.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `odd chip in a split pot goes to the first winner left of the button`() {
|
fun `odd chip in a split pot goes to the first winner left of the button`() = runTest {
|
||||||
val p0 = Scripted(Action(ActionType.CALL, 10), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
val p0 = Scripted(Action(ActionType.CALL, 10), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
||||||
val p1 = Scripted(Action(ActionType.FOLD))
|
val p1 = Scripted(Action(ActionType.FOLD))
|
||||||
val p2 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
val p2 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
||||||
@@ -216,7 +217,7 @@ class TableRulesTest {
|
|||||||
// ---------- uncalled bets ----------
|
// ---------- uncalled bets ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an uncalled bet is returned`() {
|
fun `an uncalled bet is returned`() = runTest {
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 400))
|
val p0 = Scripted(Action(ActionType.RAISE, 400))
|
||||||
val p1 = Scripted(Action(ActionType.FOLD))
|
val p1 = Scripted(Action(ActionType.FOLD))
|
||||||
val p2 = Scripted(Action(ActionType.FOLD))
|
val p2 = Scripted(Action(ActionType.FOLD))
|
||||||
@@ -237,7 +238,7 @@ class TableRulesTest {
|
|||||||
// ---------- blinds and action order ----------
|
// ---------- blinds and action order ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `heads up button posts the small blind and acts first preflop`() {
|
fun `heads up button posts the small blind and acts first preflop`() = runTest {
|
||||||
val p0 = Scripted(Action(ActionType.CALL, 5), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
val p0 = Scripted(Action(ActionType.CALL, 5), 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.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK))
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ class TableRulesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `six handed action starts left of the big blind`() {
|
fun `six handed action starts left of the big blind`() = runTest {
|
||||||
val agents = List(6) { Scripted() }
|
val agents = List(6) { Scripted() }
|
||||||
val seats = agents.mapIndexed { i, a -> Seat(i, "P$i", 500, a) }
|
val seats = agents.mapIndexed { i, a -> Seat(i, "P$i", 500, a) }
|
||||||
val result = Table(seats, 5, 10, Random(1), StackedDeck.of(List(6) { "" }.let {
|
val result = Table(seats, 5, 10, Random(1), StackedDeck.of(List(6) { "" }.let {
|
||||||
@@ -267,7 +268,7 @@ class TableRulesTest {
|
|||||||
// ---------- malformed agent output ----------
|
// ---------- malformed agent output ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `malformed actions are sanitised`() {
|
fun `malformed actions are sanitised`() = runTest {
|
||||||
// Tries to check facing a bet, and to bet far beyond its stack.
|
// Tries to check facing a bet, and to bet far beyond its stack.
|
||||||
val p0 = Scripted(Action(ActionType.RAISE, 999_999))
|
val p0 = Scripted(Action(ActionType.RAISE, 999_999))
|
||||||
val p1 = Scripted(Action(ActionType.CHECK))
|
val p1 = Scripted(Action(ActionType.CHECK))
|
||||||
@@ -293,7 +294,7 @@ class TableRulesTest {
|
|||||||
// ---------- invariants under fuzzing ----------
|
// ---------- invariants under fuzzing ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `chips are conserved and stacks stay non-negative over many random hands`() {
|
fun `chips are conserved and stacks stay non-negative over many random hands`() = runTest {
|
||||||
val random = Random(4242)
|
val random = Random(4242)
|
||||||
val seats = List(6) { Seat(it, "P$it", 400, RandomAgent(random)) }
|
val seats = List(6) { Seat(it, "P$it", 400, RandomAgent(random)) }
|
||||||
val table = Table(seats, 5, 10, random)
|
val table = Table(seats, 5, 10, random)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.2.1"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
composeBom = "2025.06.01"
|
||||||
|
activityCompose = "1.10.1"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
coreKtx = "1.18.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
alias(libs.plugins.kotlin.jvm)
|
||||||
application
|
application
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":engine"))
|
implementation(project(":engine"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
}
|
}
|
||||||
|
|
||||||
application {
|
application {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.jsjdesigns.poker.game.Seat
|
|||||||
import com.jsjdesigns.poker.game.Street
|
import com.jsjdesigns.poker.game.Street
|
||||||
import com.jsjdesigns.poker.game.Table
|
import com.jsjdesigns.poker.game.Table
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
private const val SMALL_BLIND = 1
|
private const val SMALL_BLIND = 1
|
||||||
@@ -33,7 +34,7 @@ private class Stats(val name: String, val profile: BotProfile) {
|
|||||||
if (postflopCalls == 0) postflopBets.toDouble() else postflopBets.toDouble() / postflopCalls
|
if (postflopCalls == 0) postflopBets.toDouble() else postflopBets.toDouble() / postflopCalls
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed: Long): List<Stats> {
|
private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed: Long): List<Stats> = runBlocking {
|
||||||
// The deck gets its own RNG. If bots drew from the same stream, the number of
|
// The deck gets its own RNG. If bots drew from the same stream, the number of
|
||||||
// Monte Carlo rollouts a bot performs — which varies by skill level — would
|
// Monte Carlo rollouts a bot performs — which varies by skill level — would
|
||||||
// shift every subsequent deal, so changing a profile would silently change the
|
// shift every subsequent deal, so changing a profile would silently change the
|
||||||
@@ -109,7 +110,7 @@ private fun runTable(label: String, roster: List<BotProfile>, hands: Int, seed:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
println("chip conservation: %d (must be 0)".format(stats.sumOf { it.net }))
|
println("chip conservation: %d (must be 0)".format(stats.sumOf { it.net }))
|
||||||
return stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dumps the starting-hand ranking so the ordering can be eyeballed against a real chart. */
|
/** Dumps the starting-hand ranking so the ordering can be eyeballed against a real chart. */
|
||||||
|
|||||||
Reference in New Issue
Block a user