Add language-only opponent personas
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user