From 14c35f18a3ad8b4b7fd923985ec69a7756943d67 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 26 Jul 2026 14:39:51 -0400 Subject: [PATCH] Show live table action status --- CLAUDE.md | 4 + .../java/com/jsjdesigns/poker/TableScreen.kt | 21 ++- .../java/com/jsjdesigns/poker/TableStatus.kt | 86 +++++++++++ .../com/jsjdesigns/poker/TableStatusTest.kt | 136 ++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/jsjdesigns/poker/TableStatus.kt create mode 100644 app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 59ea022..9cfaa9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,10 @@ 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. +- `TableSnapshot.lastAction` intentionally survives board-transition frames. + Live status copy may carry it into the next decision only when the event's + `street` matches the snapshot, or the first flop actor resurrects a pre-flop + action. - 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. diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index 3935819..5ea0cb0 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -50,6 +51,7 @@ fun TableScreen(vm: PokerViewModel) { // Only show an action bar that belongs to the table currently on screen. val offer = state.liveOffer(rawOffer) val handSummary = state.visibleHandSummary() + val status = snap?.let(::tableStatus) Column( modifier = Modifier @@ -96,7 +98,24 @@ fun TableScreen(vm: PokerViewModel) { fontWeight = FontWeight.Bold, fontSize = 18.sp, ) - Spacer(Modifier.height(8.dp)) + Text( + text = status?.text.orEmpty(), + color = when (status?.tone) { + TableStatusTone.ACCENT -> Color(0xFFE3C179) + TableStatusTone.NORMAL -> Color.White.copy(alpha = 0.78f) + TableStatusTone.MUTED, null -> Color.White.copy(alpha = 0.48f) + }, + fontWeight = if (status?.tone == TableStatusTone.ACCENT) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + fontSize = 13.sp, + modifier = Modifier.height(24.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { val board = snap?.board.orEmpty() repeat(5) { i -> diff --git a/app/src/main/java/com/jsjdesigns/poker/TableStatus.kt b/app/src/main/java/com/jsjdesigns/poker/TableStatus.kt new file mode 100644 index 0000000..199359e --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/TableStatus.kt @@ -0,0 +1,86 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.HandEvent +import com.jsjdesigns.poker.game.Street +import com.jsjdesigns.poker.game.TableSnapshot + +enum class TableStatusTone { ACCENT, NORMAL, MUTED } + +/** One short, snapshot-derived explanation of what the table is doing now. */ +data class TableStatus(val text: String, val tone: TableStatusTone) + +/** + * Turns an immutable engine frame into player-facing status copy. + * + * Phase transitions outrank [TableSnapshot.lastAction], because that event is + * intentionally retained across the next board frame. Without this ordering the + * freshly dealt flop would still announce the final pre-flop call. + */ +fun tableStatus( + snapshot: TableSnapshot, + heroSeat: Int = HERO_SEAT, +): TableStatus? { + if (snapshot.phase == TableSnapshot.Phase.SHOWDOWN || + snapshot.phase == TableSnapshot.Phase.COMPLETE + ) { + return null // HandResultBar owns the terminal explanation. + } + + snapshot.toAct?.let { actingSeat -> + // Carry the preceding action into the next decision frame so it remains + // readable for more than the short post-action hold. Restrict it to this + // street: the first actor on the flop must not resurrect a pre-flop call. + val previous = snapshot.lastAction + ?.takeIf { it.street == snapshot.street } + ?.description(heroSeat) + return if (actingSeat == heroSeat) { + TableStatus( + listOfNotNull("Your turn", previous).joinToString(" • "), + TableStatusTone.ACCENT, + ) + } else { + val name = snapshot.seats.firstOrNull { it.index == actingSeat }?.name ?: "Player" + TableStatus( + listOfNotNull(previous, "$name is thinking…").joinToString(" • "), + if (previous == null) TableStatusTone.MUTED else TableStatusTone.NORMAL, + ) + } + } + + return when (snapshot.phase) { + TableSnapshot.Phase.DEALT -> + TableStatus("Cards dealt", TableStatusTone.NORMAL) + TableSnapshot.Phase.STREET_COMPLETE -> + TableStatus("${snapshot.street.displayName()} dealt", TableStatusTone.NORMAL) + TableSnapshot.Phase.BETTING -> + snapshot.lastAction?.toStatus(heroSeat) + ?: TableStatus("Betting", TableStatusTone.MUTED) + TableSnapshot.Phase.SHOWDOWN, + TableSnapshot.Phase.COMPLETE, + -> null + } +} + +private fun HandEvent.toStatus(heroSeat: Int): TableStatus { + return TableStatus(description(heroSeat), TableStatusTone.NORMAL) +} + +private fun HandEvent.description(heroSeat: Int): String { + val actor = if (seat == heroSeat) "You" else name + val description = when (action.type) { + ActionType.FOLD -> "folded" + ActionType.CHECK -> "checked" + ActionType.CALL -> "called ${action.amount}" + ActionType.BET -> "bet ${action.amount}" + ActionType.RAISE -> "raised to ${action.amount}" + } + return "$actor $description" +} + +private fun Street.displayName(): String = when (this) { + Street.PREFLOP -> "Pre-flop" + Street.FLOP -> "Flop" + Street.TURN -> "Turn" + Street.RIVER -> "River" +} diff --git a/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt new file mode 100644 index 0000000..6f6b523 --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt @@ -0,0 +1,136 @@ +package com.jsjdesigns.poker + +import com.jsjdesigns.poker.game.Action +import com.jsjdesigns.poker.game.ActionType +import com.jsjdesigns.poker.game.HandEvent +import com.jsjdesigns.poker.game.SeatSnapshot +import com.jsjdesigns.poker.game.Street +import com.jsjdesigns.poker.game.TableSnapshot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TableStatusTest { + + private fun snapshot( + street: Street = Street.FLOP, + phase: TableSnapshot.Phase = TableSnapshot.Phase.BETTING, + toAct: Int? = null, + lastAction: HandEvent? = null, + ) = TableSnapshot( + handNumber = 3, + street = street, + phase = phase, + board = emptyList(), + pot = 20, + currentBet = 4, + minRaiseSize = 2, + button = 0, + seats = listOf( + seat(0, "Jay"), + seat(1, "Ada"), + ), + toAct = toAct, + toActToken = toAct?.toLong(), + lastAction = lastAction, + ) + + private fun seat(index: Int, name: String) = SeatSnapshot( + index = index, + name = name, + stack = 200, + committedThisRound = 0, + committedThisHand = 0, + folded = false, + allIn = false, + hole = null, + revealed = false, + isButton = index == 0, + ) + + private fun event( + seat: Int = 1, + name: String = "Ada", + action: Action, + ) = HandEvent(Street.FLOP, seat, name, action) + + @Test + fun `current actor carries the preceding same-street action`() { + val previous = event(action = Action(ActionType.RAISE, 12)) + + assertEquals( + TableStatus("Your turn • Ada raised to 12", TableStatusTone.ACCENT), + tableStatus(snapshot(toAct = HERO_SEAT, lastAction = previous)), + ) + val heroCall = event( + seat = HERO_SEAT, + name = "Jay", + action = Action(ActionType.CALL, 6), + ) + assertEquals( + TableStatus("You called 6 • Ada is thinking…", TableStatusTone.NORMAL), + tableStatus(snapshot(toAct = 1, lastAction = heroCall)), + ) + } + + @Test + fun `actions use honest past-tense amounts and identify hero by seat`() { + val cases = listOf( + Action(ActionType.FOLD) to "Ada folded", + Action(ActionType.CHECK) to "Ada checked", + Action(ActionType.CALL, 6) to "Ada called 6", + Action(ActionType.BET, 8) to "Ada bet 8", + Action(ActionType.RAISE, 20) to "Ada raised to 20", + ) + for ((action, expected) in cases) { + assertEquals(expected, tableStatus(snapshot(lastAction = event(action = action)))?.text) + } + + val hero = event(seat = HERO_SEAT, name = "Jay", action = Action(ActionType.FOLD)) + assertEquals("You folded", tableStatus(snapshot(lastAction = hero))?.text) + } + + @Test + fun `new cards outrank a retained action from the prior street`() { + val staleCall = event(action = Action(ActionType.CALL, 4)) + + assertEquals( + "Cards dealt", + tableStatus( + snapshot( + street = Street.PREFLOP, + phase = TableSnapshot.Phase.DEALT, + lastAction = staleCall, + ), + )?.text, + ) + assertEquals( + "Flop dealt", + tableStatus( + snapshot( + street = Street.FLOP, + phase = TableSnapshot.Phase.STREET_COMPLETE, + lastAction = staleCall, + ), + )?.text, + ) + + assertEquals( + "Ada is thinking…", + tableStatus( + snapshot( + street = Street.TURN, + phase = TableSnapshot.Phase.BETTING, + toAct = 1, + lastAction = staleCall, + ), + )?.text, + ) + } + + @Test + fun `terminal frames leave the explanation to the hand summary`() { + assertNull(tableStatus(snapshot(phase = TableSnapshot.Phase.SHOWDOWN))) + assertNull(tableStatus(snapshot(phase = TableSnapshot.Phase.COMPLETE))) + } +}