Add cash game session setup and reload

This commit is contained in:
Jay
2026-07-26 14:55:15 -04:00
parent 14c35f18a3
commit 3aa55dd2ce
7 changed files with 302 additions and 25 deletions
+3
View File
@@ -115,6 +115,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
- Android is playable in Compose. Engine snapshots are paced through a
rendezvous channel, human decisions are matched by engine-owned tokens, and a
completed hand stays on screen until the player explicitly starts the next one.
- A cash-game session begins at an explicit take-a-seat screen. Opponents may
auto-reload below the big blind; the human is never silently topped up and must
explicitly choose "Reload to N & deal" from the completed-hand screen.
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
- Not built yet: LLM persona layer and opt-in coach.
@@ -0,0 +1,52 @@
package com.jsjdesigns.poker
internal const val MAX_PLAYER_NAME_LENGTH = 16
internal const val DEFAULT_BUY_IN = 200
internal const val DEFAULT_SMALL_BLIND = 1
internal const val DEFAULT_BIG_BLIND = 2
data class CashGameConfig(
val playerName: String,
val buyIn: Int = DEFAULT_BUY_IN,
val smallBlind: Int = DEFAULT_SMALL_BLIND,
val bigBlind: Int = DEFAULT_BIG_BLIND,
)
/**
* Normalises player input once at the session boundary.
*
* "You" remains the safe default, but a supplied name is trimmed, internal
* whitespace is collapsed, and the table-sized label is bounded.
*/
fun cashGameConfig(playerName: String): CashGameConfig {
val normalised = playerName
.trim()
.replace(Regex("\\s+"), " ")
.take(MAX_PLAYER_NAME_LENGTH)
.ifBlank { "You" }
return CashGameConfig(playerName = normalised)
}
/**
* Cash-game bots may top themselves up automatically; the human never does.
* Returning indices keeps the policy pure and independently testable.
*/
fun automaticTopUpSeatIndices(
stacks: List<Int>,
heroSeat: Int,
bigBlind: Int,
): List<Int> {
require(heroSeat in stacks.indices) { "hero seat is outside the table" }
require(bigBlind > 0) { "big blind must be positive" }
return stacks.indices.filter { it != heroSeat && stacks[it] < bigBlind }
}
fun continuationButtonLabel(
nextHandRequested: Boolean,
rebuyRequired: Boolean,
buyIn: Int,
): String = when {
nextHandRequested -> "Dealing…"
rebuyRequired -> "Reload to $buyIn & deal"
else -> "Next hand"
}
@@ -13,7 +13,7 @@ class MainActivity : ComponentActivity() {
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
val vm: PokerViewModel = viewModel()
TableScreen(vm)
PokerApp(vm)
}
}
}
@@ -0,0 +1,131 @@
package com.jsjdesigns.poker
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
private val SetupFelt = Color(0xFF0B3D26)
private val TableGold = Color(0xFFE3C179)
@Composable
fun PokerApp(vm: PokerViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
if (state.gameConfig == null) {
TakeASeatScreen(onStart = vm::startGame)
} else {
TableScreen(vm)
}
}
@Composable
private fun TakeASeatScreen(onStart: (String) -> Unit) {
var playerName by rememberSaveable { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(SetupFelt)
.systemBarsPadding()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
"JSJ Holdem",
color = TableGold,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
)
Text(
"Take a seat",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = playerName,
onValueChange = { playerName = it.take(MAX_PLAYER_NAME_LENGTH) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Your name (optional)") },
placeholder = { Text("You") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = TableGold,
unfocusedBorderColor = Color.White.copy(alpha = 0.4f),
focusedLabelColor = TableGold,
unfocusedLabelColor = Color.White.copy(alpha = 0.6f),
cursorColor = TableGold,
focusedPlaceholderColor = Color.White.copy(alpha = 0.35f),
unfocusedPlaceholderColor = Color.White.copy(alpha = 0.35f),
),
)
Spacer(Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.08f)),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp)) {
Text("Six-max cash game", color = Color.White, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(
"Buy-in $DEFAULT_BUY_IN Blinds $DEFAULT_SMALL_BLIND / $DEFAULT_BIG_BLIND",
color = TableGold,
)
Spacer(Modifier.height(8.dp))
Text(
"Ada · Bruno · Cleo · Dex · Enzo",
color = Color.White.copy(alpha = 0.72f),
fontSize = 13.sp,
)
Text(
"A calibrated mix of skill levels and playing styles.",
color = Color.White.copy(alpha = 0.48f),
fontSize = 12.sp,
)
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { onStart(playerName) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2C6E49),
contentColor = Color.White,
),
) {
Text("Buy in for $DEFAULT_BUY_IN")
}
}
}
@@ -24,16 +24,15 @@ 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(
/** Null until the player has explicitly started a cash-game session. */
val gameConfig: CashGameConfig? = null,
val snapshot: TableSnapshot? = null,
val handsPlayed: Int = 0,
val handSummary: HandSummary? = null,
val nextHandRequested: Boolean = false,
val message: String? = null,
val heroNeedsRebuy: Boolean = false,
) {
/**
* The decision to show, or null.
@@ -93,34 +92,46 @@ class PokerViewModel : ViewModel() {
/** 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),
private var gameStarted = false
private lateinit var seats: List<Seat>
private lateinit var table: Table
init {
viewModelScope.launch { consumeFrames() }
}
/**
* Creates the table exactly once, after the player chooses to take a seat.
* A rapid double tap cannot launch two engines against the same HumanAgent.
*/
fun startGame(playerName: String) {
if (gameStarted) return
gameStarted = true
val config = cashGameConfig(playerName)
val roster = listOf(
BotProfile(config.playerName, 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)
Seat(i, profile.name, config.buyIn, agent)
}
table = Table(
seats = seats,
smallBlind = SMALL_BLIND,
bigBlind = BIG_BLIND,
smallBlind = config.smallBlind,
bigBlind = config.bigBlind,
random = deckRandom,
observer = { frames.send(it) },
)
viewModelScope.launch { consumeFrames() }
viewModelScope.launch(Dispatchers.Default) { playContinuously() }
_state.update { UiState(gameConfig = config) }
viewModelScope.launch(Dispatchers.Default) { playContinuously(config) }
}
private suspend fun consumeFrames() {
@@ -134,6 +145,7 @@ class PokerViewModel : ViewModel() {
// terminal board with a generic "Waiting…" message.
handSummary = current.handSummary.takeIf { sameCompletedHand },
nextHandRequested = current.nextHandRequested && sameCompletedHand,
heroNeedsRebuy = current.heroNeedsRebuy && sameCompletedHand,
)
}
delay(pacingMillis(frame))
@@ -154,10 +166,16 @@ class PokerViewModel : ViewModel() {
TableSnapshot.Phase.COMPLETE -> 1200
}
private suspend fun playContinuously() {
private suspend fun playContinuously(config: CashGameConfig) {
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
// Opponents rebuy automatically. The human can re-enter only through
// the explicit result-screen action below.
val botTopUps = automaticTopUpSeatIndices(
stacks = seats.map(Seat::stack),
heroSeat = HERO_SEAT,
bigBlind = config.bigBlind,
)
for (seatIndex in botTopUps) seats[seatIndex].stack = config.buyIn
table.advanceButton()
runCatching { table.playHand() }
.onSuccess { result ->
@@ -166,16 +184,23 @@ class PokerViewModel : ViewModel() {
result = result,
seatNames = seats.map(Seat::name),
)
val heroNeedsRebuy = seats[HERO_SEAT].stack < config.bigBlind
_state.update {
it.copy(
handsPlayed = it.handsPlayed + 1,
handSummary = summary,
nextHandRequested = false,
heroNeedsRebuy = heroNeedsRebuy,
)
}
// Do not let the next deal erase the outcome before the
// player has read it and chosen to continue.
nextHandGate.awaitRequest(summary.handNumber)
if (heroNeedsRebuy) {
// This runs on the engine coroutine after the explicit
// "Reload & deal" request, never as a silent table mutation.
seats[HERO_SEAT].stack = config.buyIn
}
}
.onFailure { return } // scope cancelled: the screen went away
}
@@ -145,6 +145,8 @@ fun TableScreen(vm: PokerViewModel) {
offer = offer,
handSummary = handSummary,
nextHandRequested = state.nextHandRequested,
rebuyRequired = state.heroNeedsRebuy,
buyIn = state.gameConfig?.buyIn ?: DEFAULT_BUY_IN,
heroFolded = hero?.folded == true,
onFold = vm::fold,
onCheckCall = vm::checkOrCall,
@@ -243,6 +245,8 @@ private fun ActionBar(
offer: com.jsjdesigns.poker.game.DecisionOffer?,
handSummary: HandSummary?,
nextHandRequested: Boolean,
rebuyRequired: Boolean,
buyIn: Int,
heroFolded: Boolean,
onFold: (Long) -> Unit,
onCheckCall: (Long) -> Unit,
@@ -254,6 +258,8 @@ private fun ActionBar(
HandResultBar(
summary = handSummary,
nextHandRequested = nextHandRequested,
rebuyRequired = rebuyRequired,
buyIn = buyIn,
onNextHand = onNextHand,
)
} else {
@@ -333,6 +339,8 @@ private fun ActionBar(
private fun HandResultBar(
summary: HandSummary,
nextHandRequested: Boolean,
rebuyRequired: Boolean,
buyIn: Int,
onNextHand: (Int) -> Unit,
) {
Card(
@@ -354,6 +362,14 @@ private fun HandResultBar(
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp,
)
if (rebuyRequired) {
Text(
"Your stack cannot cover the big blind.",
color = Color(0xFFC9545B),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
Spacer(Modifier.height(8.dp))
Button(
onClick = { onNextHand(summary.handNumber) },
@@ -366,7 +382,7 @@ private fun HandResultBar(
disabledContentColor = Color.White.copy(alpha = 0.65f),
),
) {
Text(if (nextHandRequested) "Dealing…" else "Next hand")
Text(continuationButtonLabel(nextHandRequested, rebuyRequired, buyIn))
}
}
}
@@ -0,0 +1,50 @@
package com.jsjdesigns.poker
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CashGameSessionTest {
@Test
fun `blank player name has an honest second-person fallback`() {
assertEquals("You", cashGameConfig(" ").playerName)
}
@Test
fun `player name is normalised once at the session boundary`() {
assertEquals("Jay Smith", cashGameConfig(" Jay Smith ").playerName)
assertEquals(
MAX_PLAYER_NAME_LENGTH,
cashGameConfig("A name much too long for a phone table").playerName.length,
)
}
@Test
fun `automatic top ups categorically exclude the human seat`() {
val topUps = automaticTopUpSeatIndices(
stacks = listOf(0, 0, 1, 2, 200),
heroSeat = 0,
bigBlind = 2,
)
assertEquals(listOf(1, 2), topUps)
assertFalse(HERO_SEAT in topUps)
}
@Test
fun `continuation copy promises the exact stack the reload produces`() {
assertEquals(
"Reload to 200 & deal",
continuationButtonLabel(
nextHandRequested = false,
rebuyRequired = true,
buyIn = 200,
),
)
assertEquals("Next hand", continuationButtonLabel(false, false, 200))
assertEquals("Dealing…", continuationButtonLabel(true, true, 200))
assertTrue(continuationButtonLabel(false, true, 200).contains("200"))
}
}