4f1ef45dd7
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>
172 lines
7.0 KiB
Kotlin
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()
|
|
}
|
|
}
|