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()