diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index e74bf8c..83c7033 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } kotlin { - // JVM target drives tests and the headless tuning simulator. + // JVM target drives the headless tuning simulator. jvm() // The modern KMP Android integration. `androidTarget()` is the older path. @@ -12,6 +12,12 @@ kotlin { namespace = "com.jsjdesigns.poker.engine" compileSdk = 37 minSdk = 26 + + // Run the shared tests against the Android variant too, not just compile + // it — otherwise the AAR is only ever proven to build. + withHostTestBuilder {}.configure { + isIncludeAndroidResources = true + } } sourceSets { diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt new file mode 100644 index 0000000..63d9b83 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/DecisionOffer.kt @@ -0,0 +1,42 @@ +package com.jsjdesigns.poker.game + +/** + * An immutable snapshot of the decision a human player is being asked to make. + * + * [DecisionContext] cannot cross the UI boundary: it holds a live [Seat] whose + * fields the game coroutine mutates as the hand proceeds, so anything the UI read + * from it would be a data race and would not recompose. This copies out only what + * is needed to render an action bar. + */ +data class DecisionOffer( + val handNumber: Int, + val street: Street, + val seat: Int, + val hole: List, + val board: List, + val pot: Int, + val toCall: Int, + val minRaiseTo: Int, + val maxRaiseTo: Int, + val stack: Int, + val canCheck: Boolean, + val canRaise: Boolean, +) { + /** Convenience for a call-or-check button label. */ + val callAmount: Int get() = toCall +} + +fun DecisionContext.toOffer(): DecisionOffer = DecisionOffer( + handNumber = handNumber, + street = street, + seat = seat.index, + hole = hole.toList(), + board = board.toList(), + pot = pot, + toCall = toCall, + minRaiseTo = minRaiseTo, + maxRaiseTo = maxRaiseTo, + stack = stack, + canCheck = canCheck, + canRaise = canRaise, +) diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt index 201f1f2..14a73bd 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/HumanAgent.kt @@ -1,42 +1,53 @@ package com.jsjdesigns.poker.game import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow 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 + * When it is this player's turn, [act] publishes an immutable [DecisionOffer] and * suspends until [submit] delivers a choice. Cancelling the coroutine running the - * hand — a screen closing, a game being abandoned — completes the pending wait + * hand — a screen closing, a game abandoned — completes the pending wait * exceptionally rather than leaving it parked forever. + * + * [offer] is a `StateFlow` rather than a plain property because it is written by + * the game coroutine and read by the UI thread: a bare `var` would be both a data + * race and invisible to Compose. */ 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 + private val _offer = MutableStateFlow(null) + + /** The decision awaiting input, or null when it is not this player's turn. */ + val offer: StateFlow = _offer.asStateFlow() + + val isAwaitingInput: Boolean get() = _offer.value != null 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 } + // Published after the deferred is installed, so a UI that reacts instantly + // to the offer always finds something able to receive its submission. + _offer.value = ctx.toOffer() + 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 - } + _offer.value = null + lock.withLock { pending = null } } } @@ -49,10 +60,10 @@ class HumanAgent : PlayerAgent { 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 + /** Abandons the pending decision, releasing [act] with [cause]. */ + suspend fun cancel(cause: Throwable = IllegalStateException("hand abandoned")): Boolean = + lock.withLock { + val deferred = pending ?: return@withLock false + deferred.completeExceptionally(cause) + } } 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 d666cf0..a9386e6 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -207,6 +207,7 @@ class Table( postBlinds() dealHoleCards() currentStreet = Street.PREFLOP + prepareRound(Street.PREFLOP) emit(TableSnapshot.Phase.DEALT) var street = Street.PREFLOP @@ -239,6 +240,9 @@ class Table( Street.RIVER -> { finished = true; Street.RIVER } } currentStreet = street + // Clear the previous street's betting BEFORE publishing, so the new + // board never arrives alongside last street's chips. + prepareRound(street) emit(TableSnapshot.Phase.STREET_COMPLETE) } @@ -346,7 +350,15 @@ class Table( private fun mayRaise(seat: Seat): Boolean = seat.lastActedAtBet < 0 || (currentBet - seat.lastActedAtBet) >= minRaiseSize - private suspend fun runBettingRound(street: Street, firstSeat: Int) { + /** + * Clears per-round betting state for [street]. + * + * Called *before* the new-street snapshot is published, not lazily at the top + * of the betting round: otherwise a flop snapshot still carries pre-flop + * `currentBet` and `committedThisRound`, and the UI paints stale chips in + * front of every player. + */ + private fun prepareRound(street: Street) { for (s in seats) { s.committedThisRound = 0 s.hasActed = false @@ -366,7 +378,10 @@ class Table( currentBet = 0 } minRaiseSize = bigBlind + } + /** Runs the round. [prepareRound] must already have been called for [street]. */ + private suspend fun runBettingRound(street: Street, firstSeat: Int) { if (seats.count { it.canAct } == 0) return var i = firstSeat diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt index aa300f3..a819d44 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt @@ -141,7 +141,7 @@ class SnapshotAndHumanAgentTest { 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") + assertNotNull(human.offer.value, "the UI must be able to see what is on offer") assertTrue(human.submit(Action(ActionType.CALL, 10))) assertEquals(ActionType.CALL, decision.await().type) @@ -197,3 +197,83 @@ class SnapshotAndHumanAgentTest { assertEquals(listOf(1), result.winners, "the bot wins once the human folds") } } + +class HumanOfferAndStreetStateTest { + + 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, + ) + + /** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */ + @Test + fun `cancel releases the waiting agent and reports whether anything was pending`() = runTest { + val human = HumanAgent() + val seat = Seat(0, "You", 500, human) + + assertFalse(human.cancel(), "nothing pending yet") + + val decision = async { runCatching { human.act(context(seat)) } } + while (!human.isAwaitingInput) yield() + + assertTrue(human.cancel(), "cancel reports that it released a pending wait") + assertTrue(decision.await().isFailure, "act must complete exceptionally") + assertNull(human.offer.value, "the offer must be cleared") + assertFalse(human.isAwaitingInput) + } + + @Test + fun `the published offer is an immutable copy, not a live seat`() = runTest { + val human = HumanAgent() + val seat = Seat(0, "You", 500, human) + seat.hole = intArrayOf(0, 5) + + val decision = async { human.act(context(seat)) } + while (!human.isAwaitingInput) yield() + val offer = human.offer.value!! + + // Mutating the live seat the way the game coroutine would must not be + // visible through the already-published offer. + seat.stack = 1 + seat.committedThisRound = 999 + assertEquals(500, offer.stack, "offer must not alias live seat state") + assertEquals(listOf(0, 5), offer.hole) + + human.submit(Action(ActionType.FOLD)) + decision.await() + } + + @Test + fun `a new street snapshot carries no stale betting from the previous street`() = 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 streetStarts = seen.filter { it.phase == TableSnapshot.Phase.STREET_COMPLETE } + assertTrue(streetStarts.isNotEmpty(), "there should be new-street snapshots") + for (snap in streetStarts) { + assertEquals(0, snap.currentBet, "a fresh street starts with no bet outstanding") + assertTrue( + snap.seats.all { it.committedThisRound == 0 }, + "chips from the previous street must be swept into the pot first", + ) + } + // The pot must still reflect everything committed so far. + assertTrue(streetStarts.all { it.pot > 0 }, "the pot carries forward") + } +}