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.DecisionOffer 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.flow.update 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, ) { /** * The decision to show, or null. * * An offer is only live when the table on screen is the one it belongs to. * The engine can be a frame ahead of the animation, so a raw offer could * otherwise be rendered against a stale board — or worse, against a different * hand entirely. */ fun liveOffer(offer: DecisionOffer?): DecisionOffer? { val snap = snapshot ?: return null if (offer == null) return null // Match on the engine's decision token, not hand+street. A player can face // two decisions on one street (bet, get raised, act again), so hand+street // is ambiguous and would briefly pair a new offer with a stale board. return offer.takeIf { snap.toActToken == it.token } } } /** * 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() // RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of // frames ahead of the animation, so the board on screen and the action being // offered could belong to different moments — even different hands. With no // buffer the engine is at most one frame ahead of what the player can see. private val frames = Channel(capacity = Channel.RENDEZVOUS) private val _state = MutableStateFlow(UiState()) val state: StateFlow = _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 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.update { it.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.update { s -> s.copy(handsPlayed = s.handsPlayed + 1) } } .onFailure { return } // scope cancelled: the screen went away } } /** * Submits against the token the button was rendered from, so a stale or * doubled tap is dropped rather than applied to whatever comes next. */ fun submit(token: Long, action: Action) { viewModelScope.launch { human.submit(token, action) } } fun fold(token: Long) = submit(token, Action(ActionType.FOLD)) fun checkOrCall(token: Long) { val o = offer.value ?: return if (o.token != token) return // Send what the button promised. The engine would clamp an oversized call // anyway, but the submitted action should not disagree with the label. val cost = minOf(o.toCall, o.stack) submit(token, if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, cost)) } fun raiseTo(token: Long, amount: Int) = submit(token, Action(ActionType.RAISE, amount)) override fun onCleared() { // No explicit human.cancel() here: viewModelScope is already cancelled by // this point, so a launch would never run. Cancelling the scope propagates // into HumanAgent.act()'s await, whose finally clears the pending state. frames.close() super.onCleared() } }