Files
holdem_poker/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt
T
thejayman77 4f1ef45dd7 Quote the real price when a call exceeds the stack
Facing more than you have left is a call for your whole stack — commit() clamps
the commitment — so labelling it "Call 35" when only 20 can be paid promised a
price the player could neither make nor owed. It now reads "All in 20".

The ViewModel also submits the clamped amount rather than relying on the engine
to fix it up, so the action sent never disagrees with the label shown.

Verified by reverting: both new label tests fail.

Tests: 141 -> 144 (64 engine JVM, 64 Android host, 16 app).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:53:25 -04:00

172 lines
7.0 KiB
Kotlin

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<TableSnapshot>(capacity = Channel.RENDEZVOUS)
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.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()
}
}