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:
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user