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:
Jay
2026-07-25 10:55:28 -04:00
parent 96af198097
commit 7f82251d86
11 changed files with 459 additions and 27 deletions
@@ -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)
@@ -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
}
/**
* 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<Int>)
@@ -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<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() {
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<Int, Int>()
for (c in contenders) {
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,
)