Show live table action status
This commit is contained in:
@@ -108,6 +108,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
|||||||
- Anything consuming `DecisionContext.history` across hands must key off
|
- Anything consuming `DecisionContext.history` across hands must key off
|
||||||
`handNumber`. History is cleared each hand, so a size comparison silently drops
|
`handNumber`. History is cleared each hand, so a size comparison silently drops
|
||||||
events.
|
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
|
- Android is playable in Compose. Engine snapshots are paced through a
|
||||||
rendezvous channel, human decisions are matched by engine-owned tokens, and 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.
|
completed hand stays on screen until the player explicitly starts the next one.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.
|
// Only show an action bar that belongs to the table currently on screen.
|
||||||
val offer = state.liveOffer(rawOffer)
|
val offer = state.liveOffer(rawOffer)
|
||||||
val handSummary = state.visibleHandSummary()
|
val handSummary = state.visibleHandSummary()
|
||||||
|
val status = snap?.let(::tableStatus)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -96,7 +98,24 @@ fun TableScreen(vm: PokerViewModel) {
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 18.sp,
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
val board = snap?.board.orEmpty()
|
val board = snap?.board.orEmpty()
|
||||||
repeat(5) { i ->
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user