Show live table action status

This commit is contained in:
Jay
2026-07-26 14:39:51 -04:00
parent 6e0766534a
commit 14c35f18a3
4 changed files with 246 additions and 1 deletions
@@ -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 ->
@@ -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"
}
@@ -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)))
}
}