Add cash game session setup and reload
This commit is contained in:
@@ -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 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user