Fix UI-boundary defects; run tests on the Android variant too
Two defects found reviewing the UI boundary before Compose work: 1. HumanAgent.awaiting was a plain mutable property written by the game coroutine and read by the UI — a data race, and invisible to Compose. It also exposed DecisionContext, which holds a live Seat whose fields mutate as the hand proceeds, so even a safe read could observe torn state. Replaced with an immutable DecisionOffer published through a StateFlow. A test mutates the live seat after publication and asserts the offer does not change. 2. STREET_COMPLETE was emitted after dealing the new street but before the round state was reset, so a flop snapshot carried pre-flop currentBet and committedThisRound — the UI would have painted last street's chips in front of every player alongside the new board. The reset is now prepareRound(), called before publishing. Verified: with the ordering reverted the new test fails with currentBet 10 on the flop. Also: - HumanAgent.cancel() is now covered directly; the previous test only cancelled the coroutine running act(). cancel() and submit() both report whether anything was actually pending. - Android host tests enabled via withHostTestBuilder, so the shared suite runs against the Android variant instead of the AAR merely compiling. No librsvg needed for card assets: sips rasterizes the SVGs directly at exact 2:3 dimensions, court cards and patterned backs included. Tests: 45 -> 48, now green on both jvmTest and testAndroidHostTest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// JVM target drives tests and the headless tuning simulator.
|
// JVM target drives the headless tuning simulator.
|
||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
// The modern KMP Android integration. `androidTarget()` is the older path.
|
// The modern KMP Android integration. `androidTarget()` is the older path.
|
||||||
@@ -12,6 +12,12 @@ kotlin {
|
|||||||
namespace = "com.jsjdesigns.poker.engine"
|
namespace = "com.jsjdesigns.poker.engine"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
minSdk = 26
|
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 {
|
sourceSets {
|
||||||
|
|||||||
@@ -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<Int>,
|
||||||
|
val board: List<Int>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -1,42 +1,53 @@
|
|||||||
package com.jsjdesigns.poker.game
|
package com.jsjdesigns.poker.game
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
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.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [PlayerAgent] driven by the UI rather than by code.
|
* 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
|
* 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.
|
* 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 {
|
class HumanAgent : PlayerAgent {
|
||||||
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private var pending: CompletableDeferred<Action>? = null
|
private var pending: CompletableDeferred<Action>? = null
|
||||||
|
|
||||||
/** The decision currently awaiting input, or null when it is not our turn. */
|
private val _offer = MutableStateFlow<DecisionOffer?>(null)
|
||||||
var awaiting: DecisionContext? = null
|
|
||||||
private set
|
/** The decision awaiting input, or null when it is not this player's turn. */
|
||||||
|
val offer: StateFlow<DecisionOffer?> = _offer.asStateFlow()
|
||||||
|
|
||||||
|
val isAwaitingInput: Boolean get() = _offer.value != null
|
||||||
|
|
||||||
override suspend fun act(ctx: DecisionContext): Action {
|
override suspend fun act(ctx: DecisionContext): Action {
|
||||||
val deferred = CompletableDeferred<Action>()
|
val deferred = CompletableDeferred<Action>()
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
check(pending == null) { "already awaiting a decision for this agent" }
|
check(pending == null) { "already awaiting a decision for this agent" }
|
||||||
pending = deferred
|
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 {
|
return try {
|
||||||
deferred.await()
|
deferred.await()
|
||||||
} finally {
|
} finally {
|
||||||
// Runs on normal completion AND on cancellation, so the agent is never
|
// Runs on normal completion AND on cancellation, so the agent is never
|
||||||
// left believing it is still waiting.
|
// left believing it is still waiting.
|
||||||
lock.withLock {
|
_offer.value = null
|
||||||
pending = null
|
lock.withLock { pending = null }
|
||||||
awaiting = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +60,10 @@ class HumanAgent : PlayerAgent {
|
|||||||
deferred.complete(action)
|
deferred.complete(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Abandons the pending decision, releasing [act] with a cancellation. */
|
/** Abandons the pending decision, releasing [act] with [cause]. */
|
||||||
suspend fun cancel(cause: Throwable = IllegalStateException("hand abandoned")) {
|
suspend fun cancel(cause: Throwable = IllegalStateException("hand abandoned")): Boolean =
|
||||||
lock.withLock { pending?.completeExceptionally(cause) }
|
lock.withLock {
|
||||||
}
|
val deferred = pending ?: return@withLock false
|
||||||
|
deferred.completeExceptionally(cause)
|
||||||
val isAwaitingInput: Boolean get() = awaiting != null
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class Table(
|
|||||||
postBlinds()
|
postBlinds()
|
||||||
dealHoleCards()
|
dealHoleCards()
|
||||||
currentStreet = Street.PREFLOP
|
currentStreet = Street.PREFLOP
|
||||||
|
prepareRound(Street.PREFLOP)
|
||||||
emit(TableSnapshot.Phase.DEALT)
|
emit(TableSnapshot.Phase.DEALT)
|
||||||
|
|
||||||
var street = Street.PREFLOP
|
var street = Street.PREFLOP
|
||||||
@@ -239,6 +240,9 @@ class Table(
|
|||||||
Street.RIVER -> { finished = true; Street.RIVER }
|
Street.RIVER -> { finished = true; Street.RIVER }
|
||||||
}
|
}
|
||||||
currentStreet = street
|
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)
|
emit(TableSnapshot.Phase.STREET_COMPLETE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +350,15 @@ 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 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) {
|
for (s in seats) {
|
||||||
s.committedThisRound = 0
|
s.committedThisRound = 0
|
||||||
s.hasActed = false
|
s.hasActed = false
|
||||||
@@ -366,7 +378,10 @@ class Table(
|
|||||||
currentBet = 0
|
currentBet = 0
|
||||||
}
|
}
|
||||||
minRaiseSize = bigBlind
|
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
|
if (seats.count { it.canAct } == 0) return
|
||||||
|
|
||||||
var i = firstSeat
|
var i = firstSeat
|
||||||
|
|||||||
+81
-1
@@ -141,7 +141,7 @@ class SnapshotAndHumanAgentTest {
|
|||||||
val decision = async { human.act(context(seat)) }
|
val decision = async { human.act(context(seat)) }
|
||||||
|
|
||||||
while (!human.isAwaitingInput) yield()
|
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)))
|
assertTrue(human.submit(Action(ActionType.CALL, 10)))
|
||||||
|
|
||||||
assertEquals(ActionType.CALL, decision.await().type)
|
assertEquals(ActionType.CALL, decision.await().type)
|
||||||
@@ -197,3 +197,83 @@ class SnapshotAndHumanAgentTest {
|
|||||||
assertEquals(listOf(1), result.winners, "the bot wins once the human folds")
|
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<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 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user