diff --git a/build.gradle.kts b/build.gradle.kts index 8996d45..58b2789 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,8 @@ plugins { - kotlin("multiplatform") version "2.2.10" apply false - kotlin("jvm") version "2.2.10" apply false + alias(libs.plugins.kotlin.multiplatform) 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 } diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 1652c08..e74bf8c 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -1,15 +1,26 @@ plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) } kotlin { - // JVM target drives tests and the headless simulator today. - // androidTarget() / iosArm64() slot in here later without touching commonMain. + // JVM target drives tests and the headless tuning simulator. jvm() + // The modern KMP Android integration. `androidTarget()` is the older path. + androidLibrary { + namespace = "com.jsjdesigns.poker.engine" + compileSdk = 37 + minSdk = 26 + } + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + } commonTest.dependencies { implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) } } } 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 7e5a4f8..7e3631d 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/MathBot.kt @@ -43,7 +43,7 @@ class MathBot( var lastTrace: DecisionTrace? = null private set - override fun act(ctx: DecisionContext): Action { + override suspend fun act(ctx: DecisionContext): Action { val skill = profile.skill val style = profile.style val opponents = ctx.activeOpponents.coerceAtLeast(1) diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt new file mode 100644 index 0000000..201f1f2 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt @@ -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? = 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() + 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 +} 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 6d5bca7..d666cf0 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -86,8 +86,13 @@ class DecisionContext( 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 act(ctx: DecisionContext): Action + suspend fun act(ctx: DecisionContext): Action } data class Pot(val amount: Int, val eligible: List) @@ -114,6 +119,11 @@ class Table( val bigBlind: Int, private val random: Random = Random.Default, 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 private set @@ -127,6 +137,46 @@ class Table( private var currentBet = 0 private var minRaiseSize = 0 + private var currentStreet = Street.PREFLOP + private val revealed = HashSet() + + 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() { button = nextOccupied(button) @@ -146,7 +196,7 @@ class Table( return i } - fun playHand(): HandResult { + suspend fun playHand(): HandResult { handNumber++ val startingStacks = IntArray(seats.size) { seats[it].stack } resetForHand() @@ -156,11 +206,14 @@ class Table( postBlinds() dealHoleCards() + currentStreet = Street.PREFLOP + emit(TableSnapshot.Phase.DEALT) var street = Street.PREFLOP var finished = false while (!finished) { + currentStreet = street val first = firstToAct(street) runBettingRound(street, first) @@ -185,15 +238,23 @@ class Table( Street.TURN -> { dealRiver(); 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() { deck.shuffle() board.clear() events.clear() + revealed.clear() currentBet = 0 minRaiseSize = bigBlind for (s in seats) { @@ -285,7 +346,7 @@ class Table( private fun mayRaise(seat: Seat): Boolean = 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) { s.committedThisRound = 0 s.hasActed = false @@ -318,12 +379,15 @@ class Table( val seat = seats[i] if (seat.canAct && (!seat.hasActed || seat.committedThisRound < currentBet)) { 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 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 + emit(TableSnapshot.Phase.BETTING) if (seats.count { it.contesting } <= 1) return } @@ -437,6 +501,8 @@ class Table( winners.add(w.index) } else { wentToShowdown = true + // Cards are face up from here, so snapshots may show them. + revealed.addAll(contenders.map { it.index }) val scores = HashMap() for (c in contenders) { val seven = IntArray(7) diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt new file mode 100644 index 0000000..c8e381b --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt @@ -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, + val pot: Int, + val currentBet: Int, + val minRaiseSize: Int, + val button: Int, + val seats: List, + /** 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 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?, + /** True once this hand has been turned face up at showdown. */ + val revealed: Boolean, + val isButton: Boolean, +) diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt new file mode 100644 index 0000000..aa300f3 --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt @@ -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() + 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() + 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() + 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() + // 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() + 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") + } +} 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 ec6ac08..f813df1 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/TableRulesTest.kt @@ -1,6 +1,7 @@ package com.jsjdesigns.poker.game import com.jsjdesigns.poker.core.StackedDeck +import kotlinx.coroutines.test.runTest import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals @@ -22,7 +23,7 @@ private class Scripted(private vararg val actions: Action) : PlayerAgent { private var i = 0 val offers = mutableListOf() - override fun act(ctx: DecisionContext): Action { + override suspend fun act(ctx: DecisionContext): Action { offers += Offer( ctx.street, ctx.toCall, ctx.canRaise, ctx.canCheck, 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 { - 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) 1, 2 -> if (ctx.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, ctx.toCall) else -> Action(ActionType.RAISE, ctx.minRaiseTo + random.nextInt(50)) @@ -46,7 +47,7 @@ class TableRulesTest { // ---------- incomplete (short all-in) raises ---------- @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 p1 = Scripted(Action(ActionType.RAISE, 130)) // all-in, only a 30 raise val p2 = Scripted(Action(ActionType.FOLD)) @@ -69,7 +70,7 @@ class TableRulesTest { } @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 p1 = Scripted(Action(ActionType.RAISE, 300)) // full re-raise val p2 = Scripted(Action(ActionType.FOLD)) @@ -90,7 +91,7 @@ class TableRulesTest { * 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`() { + 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, // 40 each: neither reopens alone, but together they reach 80. val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 80)) @@ -116,7 +117,7 @@ class TableRulesTest { } @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. val p3 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.CALL, 40)) val p0 = Scripted(Action(ActionType.RAISE, 120)) // all-in, +20 @@ -137,7 +138,7 @@ class TableRulesTest { } @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. val p0 = Scripted(Action(ActionType.RAISE, 100), Action(ActionType.RAISE, 500)) val p1 = Scripted(Action(ActionType.RAISE, 130)) @@ -158,7 +159,7 @@ class TableRulesTest { // ---------- side pots ---------- @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. val p0 = Scripted(Action(ActionType.RAISE, 50)) 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. */ @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 p1 = Scripted(Action(ActionType.FOLD)) val p2 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK)) @@ -216,7 +217,7 @@ class TableRulesTest { // ---------- uncalled bets ---------- @Test - fun `an uncalled bet is returned`() { + fun `an uncalled bet is returned`() = runTest { val p0 = Scripted(Action(ActionType.RAISE, 400)) val p1 = Scripted(Action(ActionType.FOLD)) val p2 = Scripted(Action(ActionType.FOLD)) @@ -237,7 +238,7 @@ class TableRulesTest { // ---------- blinds and action order ---------- @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 p1 = Scripted(Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK), Action(ActionType.CHECK)) @@ -252,7 +253,7 @@ class TableRulesTest { } @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 seats = agents.mapIndexed { i, a -> Seat(i, "P$i", 500, a) } val result = Table(seats, 5, 10, Random(1), StackedDeck.of(List(6) { "" }.let { @@ -267,7 +268,7 @@ class TableRulesTest { // ---------- malformed agent output ---------- @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. val p0 = Scripted(Action(ActionType.RAISE, 999_999)) val p1 = Scripted(Action(ActionType.CHECK)) @@ -293,7 +294,7 @@ class TableRulesTest { // ---------- invariants under fuzzing ---------- @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 seats = List(6) { Seat(it, "P$it", 400, RandomAgent(random)) } val table = Table(seats, 5, 10, random) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..76e27bf --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/sim/build.gradle.kts b/sim/build.gradle.kts index 5929248..1493885 100644 --- a/sim/build.gradle.kts +++ b/sim/build.gradle.kts @@ -1,10 +1,11 @@ plugins { - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) application } dependencies { implementation(project(":engine")) + implementation(libs.kotlinx.coroutines.core) } application { diff --git a/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt b/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt index 94f0a55..02ca357 100644 --- a/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt +++ b/sim/src/main/kotlin/com/jsjdesigns/poker/sim/Main.kt @@ -12,6 +12,7 @@ import com.jsjdesigns.poker.game.Seat import com.jsjdesigns.poker.game.Street import com.jsjdesigns.poker.game.Table import kotlin.math.abs +import kotlinx.coroutines.runBlocking import kotlin.random.Random 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 } -private fun runTable(label: String, roster: List, hands: Int, seed: Long): List { +private fun runTable(label: String, roster: List, hands: Int, seed: Long): List = runBlocking { // 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 // shift every subsequent deal, so changing a profile would silently change the @@ -109,7 +110,7 @@ private fun runTable(label: String, roster: List, hands: Int, seed: ) } 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. */