Add language-only opponent personas

This commit is contained in:
Jay
2026-07-26 20:42:36 -04:00
parent 843957d154
commit b8087a9872
14 changed files with 613 additions and 1 deletions
@@ -0,0 +1,50 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.PersonaCue
import com.jsjdesigns.poker.bot.PersonaCueId
import com.jsjdesigns.poker.bot.TableTalkLine
import com.jsjdesigns.poker.game.TableSnapshot
/**
* Builds the language-only cue from an already-applied public action.
*
* Accepting [TableSnapshot] here is safe because the richer engine type stops at
* this function. [PersonaCue] is the only value that crosses into narration.
*/
fun personaCueFor(
snapshot: TableSnapshot,
profilesBySeat: Map<Int, BotProfile>,
heroSeat: Int = HERO_SEAT,
): PersonaCue? {
if (snapshot.phase != TableSnapshot.Phase.BETTING) return null
val event = snapshot.lastAction ?: return null
if (event.seat == heroSeat || event.street != snapshot.street) return null
if (snapshot.actionNumber <= 0) return null
val profile = profilesBySeat[event.seat] ?: return null
val speaker = snapshot.seats.firstOrNull { it.index == event.seat } ?: return null
return PersonaCue(
id = PersonaCueId(snapshot.handNumber, snapshot.actionNumber),
street = snapshot.street,
speakerSeat = event.seat,
speakerName = event.name,
persona = profile.persona,
actionType = event.action.type,
amount = event.action.amount,
pot = snapshot.pot,
isAllIn = speaker.allIn,
activePlayers = snapshot.activeSeats.size,
)
}
fun UiState.acceptsTableTalk(line: TableTalkLine): Boolean {
val snap = snapshot ?: return false
return snap.phase == TableSnapshot.Phase.BETTING &&
snap.handNumber == line.cueId.handNumber &&
snap.actionNumber == line.cueId.actionNumber &&
snap.street == line.street
}
fun UiState.visibleTableTalk(): TableTalkLine? =
tableTalk.takeIf { it != null && acceptsTableTalk(it) }
@@ -7,8 +7,12 @@ import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.CoachingReview
import com.jsjdesigns.poker.bot.DecisionCoach
import com.jsjdesigns.poker.bot.MathBot
import com.jsjdesigns.poker.bot.OfflinePersonaNarrator
import com.jsjdesigns.poker.bot.PersonaArbiter
import com.jsjdesigns.poker.bot.PersonaCueId
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.bot.TableTalkLine
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.DecisionOffer
@@ -44,6 +48,7 @@ data class UiState(
val latestHeroDecisionToken: Long? = null,
/** Null only while durable aggregate history is loading. */
val playerHistory: PlayerHistory? = null,
val tableTalk: TableTalkLine? = null,
) {
/**
* The decision to show, or null.
@@ -101,6 +106,8 @@ data class UiState(
*/
class PokerViewModel(
private val historyStore: PlayerHistoryStore,
private val personaArbiter: PersonaArbiter =
PersonaArbiter(primary = OfflinePersonaNarrator()),
) : ViewModel() {
private val human = HumanAgent()
@@ -122,6 +129,8 @@ class PokerViewModel(
private var gameStarted = false
private lateinit var seats: List<Seat>
private lateinit var table: Table
private var profilesBySeat: Map<Int, BotProfile> = emptyMap()
private var lastRequestedPersonaCue: PersonaCueId? = null
init {
viewModelScope.launch { consumeFrames() }
@@ -155,6 +164,7 @@ class PokerViewModel(
BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
)
val deckRandom = Random(System.nanoTime())
profilesBySeat = roster.mapIndexed { index, profile -> index to profile }.toMap()
seats = roster.mapIndexed { i, profile ->
val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong()))
Seat(i, profile.name, config.buyIn, agent)
@@ -203,12 +213,36 @@ class PokerViewModel(
},
latestHeroDecisionToken =
heroDecisionToken ?: current.latestHeroDecisionToken,
tableTalk = current.tableTalk?.takeIf { line ->
frame.phase == TableSnapshot.Phase.BETTING &&
frame.handNumber == line.cueId.handNumber &&
frame.actionNumber == line.cueId.actionNumber &&
frame.street == line.street
},
)
}
requestPersonaLine(frame)
delay(pacingMillis(frame))
}
}
private fun requestPersonaLine(frame: TableSnapshot) {
val cue = personaCueFor(frame, profilesBySeat) ?: return
if (cue.id == lastRequestedPersonaCue) return
lastRequestedPersonaCue = cue.id
viewModelScope.launch {
val line = personaArbiter.lineFor(cue) ?: return@launch
_state.update { current ->
if (current.acceptsTableTalk(line)) {
current.copy(tableTalk = line)
} else {
current
}
}
}
}
/**
* How long to hold a frame on screen. Zero when the player is on the clock —
* never make someone wait to act — and longest for cards landing and for the
@@ -52,6 +52,7 @@ fun TableScreen(vm: PokerViewModel) {
val offer = state.liveOffer(rawOffer)
val handSummary = state.visibleHandSummary()
val coachReview = state.visibleCoachReview()
val tableTalk = state.visibleTableTalk()
val status = snap?.let(::tableStatus)
Column(
@@ -116,6 +117,15 @@ fun TableScreen(vm: PokerViewModel) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = tableTalk?.let { "${it.speakerName}: “${it.text}" }.orEmpty(),
color = Color.White.copy(alpha = 0.68f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.height(22.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
val board = snap?.board.orEmpty()
@@ -111,5 +111,6 @@ class CoachPresentationTest {
toAct = null,
toActToken = null,
lastAction = null,
actionNumber = 0,
)
}
@@ -105,6 +105,7 @@ class VisibleHandSummaryTest {
toAct = null,
toActToken = null,
lastAction = null,
actionNumber = 0,
)
@Test
@@ -33,6 +33,7 @@ class LiveOfferTest {
toAct = toAct,
toActToken = toActToken,
lastAction = null,
actionNumber = 0,
)
private fun offer(token: Long = 7L, handNumber: Int = 1, street: Street = Street.FLOP) =
@@ -0,0 +1,155 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.PersonaCueId
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.bot.TableTalkLine
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.Assert.assertTrue
import org.junit.Test
class PersonaTalkTest {
private val bruno = BotProfile(
"Bruno",
SkillLevel.ADVANCED,
PlayStyle.LOOSE_AGGRESSIVE,
"Relentless pressure.",
)
private fun snapshot(
handNumber: Int = 4,
street: Street = Street.FLOP,
actionNumber: Int = 3,
event: HandEvent = HandEvent(
Street.FLOP,
1,
"Bruno",
Action(ActionType.RAISE, 12),
),
board: List<Int> = listOf(2, 7, 9),
opponentHole: List<Int>? = listOf(40, 41),
phase: TableSnapshot.Phase = TableSnapshot.Phase.BETTING,
) = TableSnapshot(
handNumber = handNumber,
street = street,
phase = phase,
board = board,
pot = 25,
currentBet = 12,
minRaiseSize = 8,
button = 0,
seats = listOf(
seat(0, "You", hole = listOf(0, 1)),
seat(1, "Bruno", hole = opponentHole),
),
toAct = null,
toActToken = null,
lastAction = event,
actionNumber = actionNumber,
)
private fun seat(index: Int, name: String, hole: List<Int>?) = SeatSnapshot(
index = index,
name = name,
stack = 180,
committedThisRound = if (index == 1) 12 else 2,
committedThisHand = if (index == 1) 12 else 2,
folded = false,
allIn = false,
hole = hole,
revealed = false,
isButton = index == 0,
)
@Test
fun `narration cue is independent of every card in the rich snapshot`() {
val profiles = mapOf(1 to bruno)
val first = personaCueFor(snapshot(), profiles)
val differentCards = personaCueFor(
snapshot(
board = listOf(14, 22, 38),
opponentHole = listOf(10, 11),
),
profiles,
)
assertEquals(first, differentCards)
assertEquals(PersonaCueId(4, 3), first!!.id)
assertEquals(ActionType.RAISE, first.actionType)
assertEquals(12, first.amount)
assertEquals(25, first.pot)
}
@Test
fun `hero actions and stale street events never become persona cues`() {
val heroAction = HandEvent(
Street.FLOP,
HERO_SEAT,
"You",
Action(ActionType.CALL, 10),
)
val staleAction = HandEvent(
Street.PREFLOP,
1,
"Bruno",
Action(ActionType.RAISE, 6),
)
assertNull(personaCueFor(snapshot(event = heroAction), mapOf(1 to bruno)))
assertNull(personaCueFor(snapshot(event = staleAction), mapOf(1 to bruno)))
assertNull(
personaCueFor(
snapshot(phase = TableSnapshot.Phase.STREET_COMPLETE),
mapOf(1 to bruno),
),
)
}
@Test
fun `identical actions remain distinct through authoritative action number`() {
val profiles = mapOf(1 to bruno)
val first = personaCueFor(snapshot(actionNumber = 3), profiles)!!
val later = personaCueFor(snapshot(actionNumber = 7), profiles)!!
assertEquals(first.copy(id = PersonaCueId(4, 7)), later)
}
@Test
fun `table talk is visible only beside the exact action frame`() {
val line = TableTalkLine(
cueId = PersonaCueId(4, 3),
street = Street.FLOP,
speakerSeat = 1,
speakerName = "Bruno",
text = "Let's turn the dial.",
)
val matching = UiState(snapshot = snapshot(), tableTalk = line)
assertEquals(line, matching.visibleTableTalk())
assertNull(matching.copy(snapshot = snapshot(actionNumber = 4)).visibleTableTalk())
assertNull(matching.copy(snapshot = snapshot(handNumber = 5)).visibleTableTalk())
assertNull(
matching.copy(
snapshot = snapshot(
street = Street.TURN,
event = HandEvent(
Street.TURN,
1,
"Bruno",
Action(ActionType.RAISE, 12),
),
),
).visibleTableTalk(),
)
assertTrue(matching.acceptsTableTalk(line))
}
}
@@ -33,6 +33,7 @@ class TableStatusTest {
toAct = toAct,
toActToken = toAct?.toLong(),
lastAction = lastAction,
actionNumber = if (lastAction == null) 0 else 1,
)
private fun seat(index: Int, name: String) = SeatSnapshot(