diff --git a/CLAUDE.md b/CLAUDE.md index 13bdd5e..59ea022 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,9 +108,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" - Anything consuming `DecisionContext.history` across hands must key off `handNumber`. History is cleared each hand, so a size comparison silently drops events. -- Known-imperfect: win-rate magnitudes are still ~10x realistic, and several - profiles are looser than their labels (the Rock plays ~38% VPIP, should be - ~12%). Tuning is the open work. +- 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. - 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, opt-in coach, Compose UI. +- Not built yet: LLM persona layer and opt-in coach. diff --git a/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt new file mode 100644 index 0000000..38a2061 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/HandSummary.kt @@ -0,0 +1,55 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.game.HandResult + +/** + * Immutable, presentation-ready outcome for one completed hand. + * + * This is built from [HandResult], not inferred from the final table snapshot: + * side pots can produce several winners and a player's net can differ from the + * amount of any pot they won. + */ +data class HandSummary( + val handNumber: Int, + val winnerNames: List, + val potSize: Int, + val heroNet: Int, + val wentToShowdown: Boolean, +) { + val title: String + get() = when { + winnerNames.size == 1 && winnerNames.single() == "You" -> "You win" + winnerNames.size == 1 -> "${winnerNames.single()} wins" + else -> "${winnerNames.joinToString(" & ")} win" + } + + val detail: String + get() { + val ending = when { + heroNet > 0 -> "You won $heroNet" + heroNet < 0 -> "You lost ${-heroNet}" + else -> "You broke even" + } + val finish = if (wentToShowdown) "Showdown" else "Uncontested" + return "$finish • Pot $potSize • $ending" + } +} + +fun handSummaryFor( + handNumber: Int, + result: HandResult, + seatNames: List, + heroSeat: Int = HERO_SEAT, +): HandSummary { + require(heroSeat in result.net.indices) { "hero seat is outside the result" } + require(result.winners.isNotEmpty()) { "a completed hand must have a winner" } + require(result.winners.all { it in seatNames.indices }) { "winner seat is outside the roster" } + + return HandSummary( + handNumber = handNumber, + winnerNames = result.winners.map(seatNames::get), + potSize = result.potSize, + heroNet = result.net[heroSeat], + wentToShowdown = result.wentToShowdown, + ) +} diff --git a/app/src/main/java/com/jsjdesigns/poker/NextHandGate.kt b/app/src/main/java/com/jsjdesigns/poker/NextHandGate.kt new file mode 100644 index 0000000..3603471 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/NextHandGate.kt @@ -0,0 +1,23 @@ +package com.jsjdesigns.poker + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update + +/** + * A hand-numbered continuation gate. + * + * Remembering the greatest requested hand makes repeated taps idempotent: two + * taps on hand 4 can release hand 4 only, never a future hand 5. + */ +internal class NextHandGate { + private val requestedThrough = MutableStateFlow(0) + + fun request(completedHand: Int) { + requestedThrough.update { maxOf(it, completedHand) } + } + + suspend fun awaitRequest(completedHand: Int) { + requestedThrough.first { it >= completedHand } + } +} diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index c1a7acc..484c608 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -31,6 +31,8 @@ private const val BIG_BLIND = 2 data class UiState( val snapshot: TableSnapshot? = null, val handsPlayed: Int = 0, + val handSummary: HandSummary? = null, + val nextHandRequested: Boolean = false, val message: String? = null, ) { /** @@ -49,6 +51,18 @@ data class UiState( // is ambiguous and would briefly pair a new offer with a stale board. return offer.takeIf { snap.toActToken == it.token } } + + /** + * A result belongs on screen only beside the terminal frame for that hand. + * This keeps a delayed result or a new deal from being paired with the wrong + * board in exactly the same way [liveOffer] protects decisions. + */ + fun visibleHandSummary(): HandSummary? { + val snap = snapshot ?: return null + val terminal = snap.phase == TableSnapshot.Phase.SHOWDOWN || + snap.phase == TableSnapshot.Phase.COMPLETE + return handSummary.takeIf { terminal && it?.handNumber == snap.handNumber } + } } /** @@ -66,6 +80,7 @@ data class UiState( class PokerViewModel : ViewModel() { private val human = HumanAgent() + private val nextHandGate = NextHandGate() // 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 @@ -110,7 +125,17 @@ class PokerViewModel : ViewModel() { private suspend fun consumeFrames() { for (frame in frames) { - _state.update { it.copy(snapshot = frame.maskedFor(HERO_SEAT)) } + _state.update { current -> + val sameCompletedHand = current.handSummary?.handNumber == frame.handNumber + current.copy( + snapshot = frame.maskedFor(HERO_SEAT), + // Keep the result visible after the tap until the new deal + // actually reaches the screen. This avoids flashing a stale + // terminal board with a generic "Waiting…" message. + handSummary = current.handSummary.takeIf { sameCompletedHand }, + nextHandRequested = current.nextHandRequested && sameCompletedHand, + ) + } delay(pacingMillis(frame)) } } @@ -135,11 +160,41 @@ class PokerViewModel : ViewModel() { 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) } } + .onSuccess { result -> + val summary = handSummaryFor( + handNumber = table.handNumber, + result = result, + seatNames = seats.map(Seat::name), + ) + _state.update { + it.copy( + handsPlayed = it.handsPlayed + 1, + handSummary = summary, + nextHandRequested = false, + ) + } + // Do not let the next deal erase the outcome before the + // player has read it and chosen to continue. + nextHandGate.awaitRequest(summary.handNumber) + } .onFailure { return } // scope cancelled: the screen went away } } + /** + * Advances only the completed hand currently on screen. + * + * The numbered gate makes a double tap harmless, while the state check drops + * a stale callback after a configuration change or new deal. + */ + fun nextHand(completedHand: Int) { + val current = _state.value + val summary = current.visibleHandSummary() ?: return + if (summary.handNumber != completedHand || current.nextHandRequested) return + _state.update { it.copy(nextHandRequested = true) } + nextHandGate.request(completedHand) + } + /** * Submits against the token the button was rendered from, so a stale or * doubled tap is dropped rather than applied to whatever comes next. diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 2a39cd4..3935819 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -48,6 +49,7 @@ fun TableScreen(vm: PokerViewModel) { val snap = state.snapshot // Only show an action bar that belongs to the table currently on screen. val offer = state.liveOffer(rawOffer) + val handSummary = state.visibleHandSummary() Column( modifier = Modifier @@ -122,10 +124,13 @@ fun TableScreen(vm: PokerViewModel) { ActionBar( offer = offer, + handSummary = handSummary, + nextHandRequested = state.nextHandRequested, heroFolded = hero?.folded == true, onFold = vm::fold, onCheckCall = vm::checkOrCall, onRaise = vm::raiseTo, + onNextHand = vm::nextHand, ) } } @@ -217,17 +222,28 @@ private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) { @Composable private fun ActionBar( offer: com.jsjdesigns.poker.game.DecisionOffer?, + handSummary: HandSummary?, + nextHandRequested: Boolean, heroFolded: Boolean, onFold: (Long) -> Unit, onCheckCall: (Long) -> Unit, onRaise: (Long, Int) -> Unit, + onNextHand: (Int) -> Unit, ) { if (offer == null) { - Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { - Text( - if (heroFolded) "You folded — sitting out this hand" else "Waiting…", - color = Color.White.copy(alpha = 0.4f), + if (handSummary != null) { + HandResultBar( + summary = handSummary, + nextHandRequested = nextHandRequested, + onNextHand = onNextHand, ) + } else { + Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { + Text( + if (heroFolded) "You folded — sitting out this hand" else "Waiting…", + color = Color.White.copy(alpha = 0.4f), + ) + } } return } @@ -244,6 +260,11 @@ private fun ActionBar( value = raiseTo.coerceIn(buttons.sliderMin, buttons.sliderMax).toFloat(), onValueChange = { raiseTo = it.toInt() }, valueRange = buttons.sliderMin.toFloat()..buttons.sliderMax.toFloat(), + colors = SliderDefaults.colors( + thumbColor = Color(0xFFE3C179), + activeTrackColor = Color(0xFFE3C179), + inactiveTrackColor = Color.White.copy(alpha = 0.22f), + ), ) } Row( @@ -254,14 +275,20 @@ private fun ActionBar( Button( onClick = { onFold(offer.token) }, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF8F1218), + contentColor = Color.White, + ), ) { Text("Fold") } } Button( onClick = { onCheckCall(offer.token) }, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2C6E49), + contentColor = Color.White, + ), ) { Text(buttons.checkOrCallLabel) } if (buttons.showRaise) { @@ -273,13 +300,59 @@ private fun ActionBar( Button( onClick = { onRaise(offer.token, amount) }, modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF14357A), + contentColor = Color.White, + ), ) { Text(raiseLabel(buttons, amount)) } } } } } +@Composable +private fun HandResultBar( + summary: HandSummary, + nextHandRequested: Boolean, + onNextHand: (Int) -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.09f)), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + summary.title, + color = Color(0xFFE3C179), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + Text( + summary.detail, + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { onNextHand(summary.handNumber) }, + enabled = !nextHandRequested, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2C6E49), + contentColor = Color.White, + disabledContainerColor = Color(0xFF2C6E49).copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.65f), + ), + ) { + Text(if (nextHandRequested) "Dealing…" else "Next hand") + } + } + } +} + @Composable private fun CardImage(index: Int?, modifier: Modifier) { Image( diff --git a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt new file mode 100644 index 0000000..e3c41f6 --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt @@ -0,0 +1,140 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.game.HandResult +import com.jsjdesigns.poker.game.Street +import com.jsjdesigns.poker.game.TableSnapshot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HandSummaryTest { + + private fun result( + net: IntArray = intArrayOf(-8, 8, 0), + winners: List = listOf(1), + showdown: Boolean = true, + pot: Int = 16, + ) = HandResult( + board = intArrayOf(0, 1, 2, 3, 4), + net = net, + winners = winners, + wentToShowdown = showdown, + potSize = pot, + events = emptyList(), + ) + + @Test + fun `summary names the winner and reports the hero's actual net`() { + val summary = handSummaryFor( + handNumber = 7, + result = result(), + seatNames = listOf("You", "Ada", "Bruno"), + ) + + assertEquals("Ada wins", summary.title) + assertEquals("Showdown • Pot 16 • You lost 8", summary.detail) + assertEquals(7, summary.handNumber) + } + + @Test + fun `hero win and uncontested pot are explicit`() { + val summary = handSummaryFor( + handNumber = 3, + result = result( + net = intArrayOf(5, -2, -3), + winners = listOf(0), + showdown = false, + pot = 6, + ), + seatNames = listOf("You", "Ada", "Bruno"), + ) + + assertEquals("You win", summary.title) + assertEquals("Uncontested • Pot 6 • You won 5", summary.detail) + } + + @Test + fun `several side-pot winners are not described as one split pot`() { + val summary = handSummaryFor( + handNumber = 9, + result = result( + net = intArrayOf(0, 12, -12), + winners = listOf(0, 1), + pot = 40, + ), + seatNames = listOf("You", "Ada", "Bruno"), + ) + + assertEquals("You & Ada win", summary.title) + assertTrue(summary.detail.endsWith("You broke even")) + } +} + +class VisibleHandSummaryTest { + + private val summary = HandSummary(4, listOf("Ada"), 20, -4, true) + + private fun snapshot( + handNumber: Int = 4, + phase: TableSnapshot.Phase = TableSnapshot.Phase.SHOWDOWN, + ) = TableSnapshot( + handNumber = handNumber, + street = Street.RIVER, + phase = phase, + board = emptyList(), + pot = 20, + currentBet = 0, + minRaiseSize = 2, + button = 0, + seats = emptyList(), + toAct = null, + toActToken = null, + lastAction = null, + ) + + @Test + fun `summary is visible only on its own terminal frame`() { + assertEquals( + summary, + UiState(snapshot = snapshot(), handSummary = summary).visibleHandSummary(), + ) + assertNull( + UiState(snapshot = snapshot(handNumber = 5), handSummary = summary).visibleHandSummary(), + ) + assertNull( + UiState( + snapshot = snapshot(phase = TableSnapshot.Phase.BETTING), + handSummary = summary, + ).visibleHandSummary(), + ) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class NextHandGateTest { + + @Test + fun `a duplicate old tap cannot release a future hand`() = runTest { + val gate = NextHandGate() + gate.request(4) + gate.awaitRequest(4) + + val next = async { gate.awaitRequest(5) } + runCurrent() + assertFalse(next.isCompleted) + + gate.request(4) + runCurrent() + assertFalse(next.isCompleted) + + gate.request(5) + runCurrent() + assertTrue(next.isCompleted) + } +}