Playable Android table

The game runs on device: verified on a Pixel 10 Pro emulator (Android 17) by
installing, tapping through a hand, and confirming it advanced pre-flop to flop
with correct pot, folds, and re-offered action.

App:
- :app module on AGP 9.2.1. Note AGP 9 has built-in Kotlin support, so applying
  org.jetbrains.kotlin.android conflicts with it ("extension with name 'kotlin'
  already registered"); only android.application + kotlin.compose are applied,
  matching recipeze.
- PokerViewModel runs a continuous cash game and publishes to Compose.
- Compose table: opponents, board, pot, hero, action bar with a raise slider.

Frames are queued, not conflated. An all-in runout emits flop, turn and river
microseconds apart; pushing those into a StateFlow would collapse them and the
board would jump from empty to complete. The engine's suspending observer sends
into a Channel, a consumer paces each frame, and only then is StateFlow updated
— so backpressure paces the engine rather than the UI dropping frames. Three
tests cover this, including a characterisation test showing a conflating
StateFlow does lose the intermediate frames.

Assets:
- tools/generate_card_assets.sh rasterises the SVGs into four density buckets
  using sips, which renders SVG directly — no librsvg or ImageMagick.
- Resource names are prefixed card_ because Android resource names may not start
  with a digit (10_of_clubs would be rejected).
- CardArt.kt maps deck index to drawable via static R references, so R8 resource
  shrinking cannot strip the artwork the way getIdentifier lookups would risk.

Layout fixes found by actually looking at the running app: five opponents did
not fit a fixed-width scrolling row (Enzo was off-screen), the header collided
with the status bar clock, and the board floated against a large dead space.

Tests: 52 -> 55, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 17:23:34 -04:00
parent 679b25e2c7
commit 950c7ceb57
234 changed files with 726 additions and 5 deletions
@@ -0,0 +1,104 @@
package com.jsjdesigns.poker.game
import com.jsjdesigns.poker.core.StackedDeck
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
private class Shoves : PlayerAgent {
override suspend fun act(ctx: DecisionContext): Action = when {
ctx.canRaise -> Action(ActionType.RAISE, ctx.maxRaiseTo)
ctx.toCall > 0 -> Action(ActionType.CALL, ctx.toCall)
else -> Action(ActionType.CHECK)
}
}
/**
* How snapshots reach the UI matters as much as what is in them.
*
* An all-in runout emits flop, turn and river within microseconds. A consumer
* that keeps only the latest value will show the player an empty board and then a
* complete one, losing the runout entirely.
*/
class SnapshotDeliveryTest {
private fun table(observer: suspend (TableSnapshot) -> Unit, seats: List<Seat>) = Table(
seats = seats,
smallBlind = 5,
bigBlind = 10,
random = Random(1),
deck = StackedDeck.of(listOf("Ah Ad", "Kh Kd"), "2c 7d 9s Jc 3h"),
observer = observer,
)
@Test
fun `a channel preserves every runout frame`() = runTest {
val received = mutableListOf<TableSnapshot>()
val channel = Channel<TableSnapshot>(capacity = 32)
val consumer = launch {
for (frame in channel) received += frame
}
val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
table({ channel.send(it) }, seats).playHand()
channel.close()
consumer.join()
val boardSizes = received.map { it.board.size }.distinct().sorted()
assertEquals(
listOf(0, 3, 4, 5), boardSizes,
"every stage of the board must survive delivery",
)
}
/**
* Documents *why* the channel is required: the same run through a conflating
* StateFlow drops intermediate frames. This is a characterisation test — if it
* ever starts preserving them, the reasoning above can be revisited.
*/
@Test
fun `a conflating StateFlow loses runout frames`() = runTest {
val flow = MutableStateFlow<TableSnapshot?>(null)
val observed = mutableListOf<TableSnapshot>()
val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
// A collector that is not actively suspended on every emission — exactly the
// situation a recomposing UI is in — sees only whatever the latest value is.
table({ flow.value = it; observed += flow.value!! }, seats).playHand()
// The final value is all a late subscriber would ever see.
val lateSubscriberSees = flow.value
assertEquals(5, lateSubscriberSees?.board?.size, "only the river survives")
assertTrue(
observed.map { it.board.size }.distinct().size > 1,
"the engine did emit intermediate frames; conflation is what loses them",
)
}
@Test
fun `backpressure lets a slow consumer keep up without dropping frames`() = runTest {
val received = mutableListOf<TableSnapshot>()
// Capacity 1 forces the engine to wait on nearly every emission.
val channel = Channel<TableSnapshot>(capacity = 1)
val consumer = launch {
for (frame in channel) received += frame
}
val seats = listOf(Seat(0, "A", 100, Shoves()), Seat(1, "B", 100, Shoves()))
table({ channel.send(it) }, seats).playHand()
channel.close()
consumer.join()
assertEquals(
listOf(0, 3, 4, 5), received.map { it.board.size }.distinct().sorted(),
"a suspending observer means the engine cannot outrun the UI",
)
}
}