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