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
+15 -1
View File
@@ -75,6 +75,11 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
and coach analysis run on different coroutines. History is non-critical: an and coach analysis run on different coroutines. History is non-critical: an
unreadable/newer schema or failed commit falls back to in-memory counters and unreadable/newer schema or failed commit falls back to in-memory counters and
disables persistence for that process rather than crashing or overwriting data. disables persistence for that process rather than crashing or overwriting data.
9. **Personas can speak, never act.** `PersonaCue` contains only an opponent's
already-accepted public action and public table totals. It has no cards,
equity, decision context, legal actions, or mutation callback.
`PersonaNarrator` returns only nullable text; `PersonaArbiter` rate-limits,
times out, bounds, and sanitises that text with a deterministic offline voice.
## Testing notes ## Testing notes
@@ -130,6 +135,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
Live status copy may carry it into the next decision only when the event's 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 `street` matches the snapshot, or the first flop actor resurrects a pre-flop
action. action.
- `TableSnapshot.actionNumber` is the authoritative identity of `lastAction`.
Persona output is accepted only beside the exact hand, street, and action
number that requested it; a late response is dropped rather than shown against
a newer decision.
- 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.
@@ -147,6 +156,11 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
ten coached decisions; smaller samples are labelled as insufficient. Schema ten coached decisions; smaller samples are labelled as insufficient. Schema
reads go through an explicit migration dispatcher; add the migration branch reads go through an explicit migration dispatcher; add the migration branch
before incrementing `PLAYER_HISTORY_SCHEMA_VERSION`. before incrementing `PLAYER_HISTORY_SCHEMA_VERSION`.
- Opponents have deterministic offline table voices behind the language-only
persona arbiter. A future network narrator implements the same nullable-text
interface and inherits its timeout, fallback, output bounds, and stale-action
checks without gaining access to poker decisions.
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's - Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build. own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
- Not built yet: LLM persona layer. - Not built yet: network-backed LLM narrator. The persona boundary and offline
fallback are built.
@@ -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.CoachingReview
import com.jsjdesigns.poker.bot.DecisionCoach import com.jsjdesigns.poker.bot.DecisionCoach
import com.jsjdesigns.poker.bot.MathBot 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.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.bot.TableTalkLine
import com.jsjdesigns.poker.game.Action import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.DecisionOffer import com.jsjdesigns.poker.game.DecisionOffer
@@ -44,6 +48,7 @@ data class UiState(
val latestHeroDecisionToken: Long? = null, val latestHeroDecisionToken: Long? = null,
/** Null only while durable aggregate history is loading. */ /** Null only while durable aggregate history is loading. */
val playerHistory: PlayerHistory? = null, val playerHistory: PlayerHistory? = null,
val tableTalk: TableTalkLine? = null,
) { ) {
/** /**
* The decision to show, or null. * The decision to show, or null.
@@ -101,6 +106,8 @@ data class UiState(
*/ */
class PokerViewModel( class PokerViewModel(
private val historyStore: PlayerHistoryStore, private val historyStore: PlayerHistoryStore,
private val personaArbiter: PersonaArbiter =
PersonaArbiter(primary = OfflinePersonaNarrator()),
) : ViewModel() { ) : ViewModel() {
private val human = HumanAgent() private val human = HumanAgent()
@@ -122,6 +129,8 @@ class PokerViewModel(
private var gameStarted = false private var gameStarted = false
private lateinit var seats: List<Seat> private lateinit var seats: List<Seat>
private lateinit var table: Table private lateinit var table: Table
private var profilesBySeat: Map<Int, BotProfile> = emptyMap()
private var lastRequestedPersonaCue: PersonaCueId? = null
init { init {
viewModelScope.launch { consumeFrames() } viewModelScope.launch { consumeFrames() }
@@ -155,6 +164,7 @@ class PokerViewModel(
BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."), BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
) )
val deckRandom = Random(System.nanoTime()) val deckRandom = Random(System.nanoTime())
profilesBySeat = roster.mapIndexed { index, profile -> index to profile }.toMap()
seats = roster.mapIndexed { i, profile -> seats = roster.mapIndexed { i, profile ->
val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong())) val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong()))
Seat(i, profile.name, config.buyIn, agent) Seat(i, profile.name, config.buyIn, agent)
@@ -203,12 +213,36 @@ class PokerViewModel(
}, },
latestHeroDecisionToken = latestHeroDecisionToken =
heroDecisionToken ?: current.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)) 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 — * 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 * 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 offer = state.liveOffer(rawOffer)
val handSummary = state.visibleHandSummary() val handSummary = state.visibleHandSummary()
val coachReview = state.visibleCoachReview() val coachReview = state.visibleCoachReview()
val tableTalk = state.visibleTableTalk()
val status = snap?.let(::tableStatus) val status = snap?.let(::tableStatus)
Column( Column(
@@ -116,6 +117,15 @@ fun TableScreen(vm: PokerViewModel) {
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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)) 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()
@@ -111,5 +111,6 @@ class CoachPresentationTest {
toAct = null, toAct = null,
toActToken = null, toActToken = null,
lastAction = null, lastAction = null,
actionNumber = 0,
) )
} }
@@ -105,6 +105,7 @@ class VisibleHandSummaryTest {
toAct = null, toAct = null,
toActToken = null, toActToken = null,
lastAction = null, lastAction = null,
actionNumber = 0,
) )
@Test @Test
@@ -33,6 +33,7 @@ class LiveOfferTest {
toAct = toAct, toAct = toAct,
toActToken = toActToken, toActToken = toActToken,
lastAction = null, lastAction = null,
actionNumber = 0,
) )
private fun offer(token: Long = 7L, handNumber: Int = 1, street: Street = Street.FLOP) = 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, toAct = toAct,
toActToken = toAct?.toLong(), toActToken = toAct?.toLong(),
lastAction = lastAction, lastAction = lastAction,
actionNumber = if (lastAction == null) 0 else 1,
) )
private fun seat(index: Int, name: String) = SeatSnapshot( private fun seat(index: Int, name: String) = SeatSnapshot(
@@ -0,0 +1,222 @@
package com.jsjdesigns.poker.bot
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.Street
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
data class PersonaCueId(
val handNumber: Int,
/** One-based, monotonic within a hand. */
val actionNumber: Int,
)
/**
* The complete language-model input boundary.
*
* Every value is public and describes an action the engine has already accepted.
* There are deliberately no cards, equity, legal-action choices, mutable seats,
* or means to return a poker action.
*/
data class PersonaCue(
val id: PersonaCueId,
val street: Street,
val speakerSeat: Int,
val speakerName: String,
val persona: String,
val actionType: ActionType,
val amount: Int,
val pot: Int,
val isAllIn: Boolean,
val activePlayers: Int,
) {
init {
require(id.handNumber > 0)
require(id.actionNumber > 0)
require(speakerSeat >= 0)
require(speakerName.isNotBlank())
require(amount >= 0)
require(pot >= 0)
require(activePlayers > 0)
}
}
data class TableTalkLine(
val cueId: PersonaCueId,
val street: Street,
val speakerSeat: Int,
val speakerName: String,
val text: String,
)
fun interface PersonaNarrator {
/** Returns language only; null means this narrator elects not to speak. */
suspend fun narrate(cue: PersonaCue): String?
}
/**
* Decides when language is appropriate, bounds latency/output, and supplies a
* deterministic offline fallback. It never participates in [MathBot.act].
*/
class PersonaArbiter(
private val primary: PersonaNarrator,
private val fallback: PersonaNarrator = OfflinePersonaNarrator(),
private val timeoutMillis: Long = 1_200,
private val maxCharacters: Int = 96,
) {
init {
require(timeoutMillis > 0)
require(maxCharacters > 0)
}
suspend fun lineFor(cue: PersonaCue): TableTalkLine? {
if (!shouldRequestPersonaLine(cue)) return null
val text = when (val result = request(primary, cue)) {
is NarrationResult.Completed -> {
// Null is a deliberate choice not to speak; blank/invalid output
// falls back because it cannot be rendered honestly.
if (result.text == null) return null
sanitise(result.text) ?: fallbackText(cue)
}
NarrationResult.Failed -> fallbackText(cue)
} ?: return null
return TableTalkLine(
cueId = cue.id,
street = cue.street,
speakerSeat = cue.speakerSeat,
speakerName = cue.speakerName,
text = text,
)
}
private suspend fun fallbackText(cue: PersonaCue): String? =
when (val result = request(fallback, cue)) {
is NarrationResult.Completed -> sanitise(result.text)
NarrationResult.Failed -> null
}
private suspend fun request(
narrator: PersonaNarrator,
cue: PersonaCue,
): NarrationResult {
return try {
withTimeoutOrNull(timeoutMillis) {
// Wrap null so an intentional choice not to speak is distinct
// from this timeout returning null.
NarrationResult.Completed(narrator.narrate(cue))
} ?: NarrationResult.Failed
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: RuntimeException) {
NarrationResult.Failed
}
}
private fun sanitise(raw: String?): String? {
val singleLine = raw
?.replace(WHITESPACE, " ")
?.trim()
?.take(maxCharacters)
?.trim()
return singleLine?.takeIf(String::isNotEmpty)
}
private companion object {
val WHITESPACE = Regex("\\s+")
}
private sealed interface NarrationResult {
data class Completed(val text: String?) : NarrationResult
data object Failed : NarrationResult
}
}
/**
* Stable rate gate: lively after aggression and all-ins, restrained otherwise.
* Re-reading the same frame can never change whether somebody speaks.
*/
internal fun shouldRequestPersonaLine(cue: PersonaCue): Boolean {
if (cue.isAllIn) return true
val chance = when (cue.actionType) {
ActionType.BET, ActionType.RAISE -> 55
ActionType.CALL -> 20
ActionType.FOLD -> 12
ActionType.CHECK -> 8
}
var hash = 17
hash = hash * 31 + cue.id.handNumber
hash = hash * 31 + cue.id.actionNumber
hash = hash * 31 + cue.speakerSeat
hash = hash * 31 + cue.actionType.ordinal
return (hash and Int.MAX_VALUE) % 100 < chance
}
/**
* Network-free voice used today and whenever a future narrator times out.
*
* Lines react only to public action shape and never claim private hand strength.
*/
class OfflinePersonaNarrator : PersonaNarrator {
override suspend fun narrate(cue: PersonaCue): String {
val lines = linesFor(cue)
var hash = cue.speakerName.hashCode()
hash = hash * 31 + cue.persona.hashCode()
hash = hash * 31 + cue.id.handNumber
hash = hash * 31 + cue.id.actionNumber
return lines[(hash and Int.MAX_VALUE) % lines.size]
}
private fun linesFor(cue: PersonaCue): List<String> {
val action = when (cue.actionType) {
ActionType.BET, ActionType.RAISE -> "pressure"
ActionType.CALL -> "call"
ActionType.CHECK -> "check"
ActionType.FOLD -> "fold"
}
return VOICES[cue.speakerName]?.get(action)
?: GENERIC.getValue(action)
}
private companion object {
val GENERIC = mapOf(
"pressure" to listOf("Let's make this interesting.", "Your move."),
"call" to listOf("I'll take another look.", "Still here."),
"check" to listOf("Go ahead.", "Let's see what develops."),
"fold" to listOf("Not this one.", "You can have it."),
)
val VOICES = mapOf(
"Ada" to mapOf(
"pressure" to listOf("Pressure clarifies things.", "Your decision."),
"call" to listOf("Continue.", "Noted."),
"check" to listOf("Proceed.", "For now."),
"fold" to listOf("No value in this one.", "Next hand."),
),
"Bruno" to mapOf(
"pressure" to listOf("Let's turn the dial.", "Comfortable yet?"),
"call" to listOf("I'm not going anywhere.", "Keep talking."),
"check" to listOf("Show me something.", "After you."),
"fold" to listOf("Enjoy the small one.", "Take it."),
),
"Cleo" to mapOf(
"pressure" to listOf("A little louder this time.", "Let's add some weight."),
"call" to listOf("I'll stay.", "One more card."),
"check" to listOf("Quiet is useful.", "I'm listening."),
"fold" to listOf("Nothing to prove.", "I'll wait."),
),
"Dex" to mapOf(
"pressure" to listOf("Maybe this gets us somewhere.", "Worth a try."),
"call" to listOf("Worth a look.", "I have to see it."),
"check" to listOf("Free card? Sure.", "Let's keep it friendly."),
"fold" to listOf("Okay, okay.", "Not worth the ticket."),
),
"Enzo" to mapOf(
"pressure" to listOf("Now we're playing!", "More chips, more truth!"),
"call" to listOf("Of course I'm in.", "You can't lose me that easily."),
"check" to listOf("A tactical pause!", "Suspense is part of the game."),
"fold" to listOf("Strategic retreat!", "I was being merciful."),
),
)
}
}
@@ -189,6 +189,7 @@ class Table(
toAct = toAct, toAct = toAct,
toActToken = if (toAct != null) pendingToken else null, toActToken = if (toAct != null) pendingToken else null,
lastAction = events.lastOrNull(), lastAction = events.lastOrNull(),
actionNumber = events.size,
) )
private suspend fun emit( private suspend fun emit(
@@ -25,6 +25,8 @@ data class TableSnapshot(
/** Identity of the decision [toAct] is being asked for; null when nobody is. */ /** Identity of the decision [toAct] is being asked for; null when nobody is. */
val toActToken: Long?, val toActToken: Long?,
val lastAction: HandEvent?, val lastAction: HandEvent?,
/** Number of accepted actions in this hand; authoritative identity for [lastAction]. */
val actionNumber: Int,
) { ) {
enum class Phase { DEALT, BETTING, STREET_COMPLETE, SHOWDOWN, COMPLETE } enum class Phase { DEALT, BETTING, STREET_COMPLETE, SHOWDOWN, COMPLETE }
@@ -0,0 +1,105 @@
package com.jsjdesigns.poker.bot
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.Street
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class PersonaArbiterTest {
private fun cue(
actionNumber: Int = 3,
actionType: ActionType = ActionType.RAISE,
allIn: Boolean = true,
) = PersonaCue(
id = PersonaCueId(handNumber = 2, actionNumber = actionNumber),
street = Street.TURN,
speakerSeat = 2,
speakerName = "Bruno",
persona = "Relentless pressure.",
actionType = actionType,
amount = 24,
pot = 51,
isAllIn = allIn,
activePlayers = 3,
)
@Test
fun `offline voice is deterministic for the same accepted action`() = runTest {
val narrator = OfflinePersonaNarrator()
val decision = cue()
assertEquals(narrator.narrate(decision), narrator.narrate(decision))
}
@Test
fun `arbiter returns bounded single-line language with authoritative identity`() = runTest {
val arbiter = PersonaArbiter(
primary = PersonaNarrator { " First line\nsecond\tline with extra words " },
maxCharacters = 24,
)
val line = assertNotNull(arbiter.lineFor(cue()))
assertEquals(PersonaCueId(2, 3), line.cueId)
assertEquals(Street.TURN, line.street)
assertEquals(2, line.speakerSeat)
assertEquals("Bruno", line.speakerName)
assertEquals("First line second line w", line.text)
assertTrue('\n' !in line.text)
assertTrue(line.text.length <= 24)
}
@Test
fun `runtime failure and timeout both fall back offline`() = runTest {
val failure = PersonaArbiter(
primary = PersonaNarrator { throw IllegalStateException("network failed") },
)
val timeout = PersonaArbiter(
primary = PersonaNarrator {
delay(500)
"too late"
},
timeoutMillis = 10,
)
assertNotNull(failure.lineFor(cue(actionNumber = 4)))
assertNotNull(timeout.lineFor(cue(actionNumber = 5)))
}
@Test
fun `narrator choosing silence is not replaced by fallback chatter`() = runTest {
val arbiter = PersonaArbiter(
primary = PersonaNarrator { null },
fallback = PersonaNarrator { "fallback" },
)
assertEquals(null, arbiter.lineFor(cue()))
}
@Test
fun `coroutine cancellation is never converted into table talk`() = runTest {
val arbiter = PersonaArbiter(
primary = PersonaNarrator { throw CancellationException("screen left") },
)
assertFailsWith<CancellationException> { arbiter.lineFor(cue()) }
}
@Test
fun `rate gate is stable and all-in speech is always eligible`() {
val ordinary = cue(allIn = false)
assertEquals(
shouldRequestPersonaLine(ordinary),
shouldRequestPersonaLine(ordinary),
)
assertTrue(shouldRequestPersonaLine(cue(allIn = true)))
}
}
@@ -80,6 +80,21 @@ class SnapshotAndHumanAgentTest {
seen.last().phase == TableSnapshot.Phase.COMPLETE, seen.last().phase == TableSnapshot.Phase.COMPLETE,
"the hand must end on a terminal phase, was ${seen.last().phase}", "the hand must end on a terminal phase, was ${seen.last().phase}",
) )
assertTrue(
seen.zipWithNext().all { (before, after) ->
after.actionNumber >= before.actionNumber
},
"accepted-action identity must never move backwards",
)
assertEquals(
(0..seen.maxOf { it.actionNumber }).toSet(),
seen.map { it.actionNumber }.toSet(),
"every accepted action number must be published",
)
assertTrue(
seen.all { (it.actionNumber == 0) == (it.lastAction == null) },
"action identity and lastAction must appear together",
)
} }
@Test @Test