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,139 @@
package com.jsjdesigns.poker
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.MathBot
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.HumanAgent
import com.jsjdesigns.poker.game.Seat
import com.jsjdesigns.poker.game.Table
import com.jsjdesigns.poker.game.TableSnapshot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.random.Random
const val HERO_SEAT = 0
private const val STARTING_STACK = 200
private const val SMALL_BLIND = 1
private const val BIG_BLIND = 2
data class UiState(
val snapshot: TableSnapshot? = null,
val handsPlayed: Int = 0,
val message: String? = null,
)
/**
* Drives a continuous cash game and publishes it to Compose.
*
* **Frames are queued, not conflated.** The engine can emit the flop, turn and
* river of an all-in runout within microseconds of each other. Pushing those
* straight into a `StateFlow` would collapse them — `StateFlow` keeps only the
* latest value — and the board would appear to jump from empty to complete.
* Instead the engine's suspending observer sends into a [Channel], a consumer
* paces each frame, and only then is `StateFlow` updated. Because the observer
* suspends when the channel is full, the engine cannot outrun the animation:
* backpressure does the pacing for us.
*/
class PokerViewModel : ViewModel() {
private val human = HumanAgent()
private val frames = Channel<TableSnapshot>(capacity = 32)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
/** What the player is being asked to decide, or null when it isn't their turn. */
val offer = human.offer
private val roster = listOf(
BotProfile("You", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
BotProfile("Bruno", SkillLevel.ADVANCED, PlayStyle.LOOSE_AGGRESSIVE, "Relentless pressure."),
BotProfile("Cleo", SkillLevel.INTERMEDIATE, PlayStyle.TRAPPER, "Quiet until she has you."),
BotProfile("Dex", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION, "Pays to see it."),
BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
)
private val seats: List<Seat>
private val table: Table
init {
val deckRandom = Random(System.nanoTime())
seats = roster.mapIndexed { i, profile ->
val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong()))
Seat(i, profile.name, STARTING_STACK, agent)
}
table = Table(
seats = seats,
smallBlind = SMALL_BLIND,
bigBlind = BIG_BLIND,
random = deckRandom,
observer = { frames.send(it) },
)
viewModelScope.launch { consumeFrames() }
viewModelScope.launch(Dispatchers.Default) { playContinuously() }
}
private suspend fun consumeFrames() {
for (frame in frames) {
_state.value = _state.value.copy(snapshot = frame.maskedFor(HERO_SEAT))
delay(pacingMillis(frame))
}
}
/**
* How long to hold a frame on screen. Zero when the player is on the clock —
* never make someone wait to act — and longest for cards landing and for the
* showdown, which are the moments worth watching.
*/
private fun pacingMillis(frame: TableSnapshot): Long = when (frame.phase) {
TableSnapshot.Phase.DEALT -> 400
TableSnapshot.Phase.BETTING ->
if (frame.toAct == HERO_SEAT) 0 else if (frame.toAct != null) 250 else 450
TableSnapshot.Phase.STREET_COMPLETE -> 700
TableSnapshot.Phase.SHOWDOWN -> 2200
TableSnapshot.Phase.COMPLETE -> 1200
}
private suspend fun playContinuously() {
while (true) {
// Cash-game convention: top anyone back up who cannot cover a blind.
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
table.advanceButton()
runCatching { table.playHand() }
.onSuccess { _state.value = _state.value.copy(handsPlayed = _state.value.handsPlayed + 1) }
.onFailure { return } // scope cancelled: the screen went away
}
}
fun submit(action: Action) {
viewModelScope.launch { human.submit(action) }
}
fun fold() = submit(Action(ActionType.FOLD))
fun checkOrCall() {
val o = offer.value ?: return
submit(if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.toCall))
}
fun raiseTo(amount: Int) = submit(Action(ActionType.RAISE, amount))
override fun onCleared() {
// Release a hand parked on human input so the coroutine can finish.
viewModelScope.launch { human.cancel() }
frames.close()
super.onCleared()
}
}