diff --git a/CLAUDE.md b/CLAUDE.md index 9cfaa9b..b485d6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt b/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt new file mode 100644 index 0000000..c17f412 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/CashGameSession.kt @@ -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, + heroSeat: Int, + bigBlind: Int, +): List { + 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" +} diff --git a/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt b/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt index b8f5235..bbf2fa8 100644 --- a/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt +++ b/app/src/main/java/com/jsjdesigns/poker/MainActivity.kt @@ -13,7 +13,7 @@ class MainActivity : ComponentActivity() { setContent { MaterialTheme(colorScheme = darkColorScheme()) { val vm: PokerViewModel = viewModel() - TableScreen(vm) + PokerApp(vm) } } } diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt b/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt new file mode 100644 index 0000000..fdd9e82 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/PokerApp.kt @@ -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 Hold’em", + 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") + } + } +} diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index 484c608..d005cfe 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -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), - 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 + private var gameStarted = false + private lateinit var seats: List + 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."), + ) 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 } diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 5ea0cb0..673d0d3 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -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)) } } } diff --git a/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt b/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt new file mode 100644 index 0000000..1f134da --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/CashGameSessionTest.kt @@ -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")) + } +}