diff --git a/CLAUDE.md b/CLAUDE.md index 971d0b9..91fcf2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 unreadable/newer schema or failed commit falls back to in-memory counters and 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 @@ -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 `street` matches the snapshot, or the first flop actor resurrects a pre-flop 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 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. @@ -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 reads go through an explicit migration dispatcher; add the migration branch 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 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. diff --git a/app/src/main/java/com/jsjdesigns/poker/PersonaTalk.kt b/app/src/main/java/com/jsjdesigns/poker/PersonaTalk.kt new file mode 100644 index 0000000..7aadb55 --- /dev/null +++ b/app/src/main/java/com/jsjdesigns/poker/PersonaTalk.kt @@ -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, + 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) } diff --git a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt index 6f3ba8f..e1601fb 100644 --- a/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt +++ b/app/src/main/java/com/jsjdesigns/poker/PokerViewModel.kt @@ -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 private lateinit var table: Table + private var profilesBySeat: Map = 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 diff --git a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt index add54d8..f523abb 100644 --- a/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt +++ b/app/src/main/java/com/jsjdesigns/poker/TableScreen.kt @@ -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() diff --git a/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt b/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt index d3dae9a..3cea0d0 100644 --- a/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/CoachPresentationTest.kt @@ -111,5 +111,6 @@ class CoachPresentationTest { toAct = null, toActToken = null, lastAction = null, + actionNumber = 0, ) } diff --git a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt index 910f28b..78d8f86 100644 --- a/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/HandSummaryTest.kt @@ -105,6 +105,7 @@ class VisibleHandSummaryTest { toAct = null, toActToken = null, lastAction = null, + actionNumber = 0, ) @Test diff --git a/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt b/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt index 5a415cc..c0d6e83 100644 --- a/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/LiveOfferTest.kt @@ -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) = diff --git a/app/src/test/java/com/jsjdesigns/poker/PersonaTalkTest.kt b/app/src/test/java/com/jsjdesigns/poker/PersonaTalkTest.kt new file mode 100644 index 0000000..90383c2 --- /dev/null +++ b/app/src/test/java/com/jsjdesigns/poker/PersonaTalkTest.kt @@ -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 = listOf(2, 7, 9), + opponentHole: List? = 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?) = 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)) + } +} diff --git a/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt b/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt index 6f6b523..f60fc0c 100644 --- a/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt +++ b/app/src/test/java/com/jsjdesigns/poker/TableStatusTest.kt @@ -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( diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/PersonaArbiter.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/PersonaArbiter.kt new file mode 100644 index 0000000..c173288 --- /dev/null +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/bot/PersonaArbiter.kt @@ -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 { + 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."), + ), + ) + } +} diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt index ac31910..bf03b24 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/Table.kt @@ -189,6 +189,7 @@ class Table( toAct = toAct, toActToken = if (toAct != null) pendingToken else null, lastAction = events.lastOrNull(), + actionNumber = events.size, ) private suspend fun emit( diff --git a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt index a0a8638..294a63f 100644 --- a/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt +++ b/engine/src/commonMain/kotlin/com/jsjdesigns/poker/game/TableSnapshot.kt @@ -25,6 +25,8 @@ data class TableSnapshot( /** Identity of the decision [toAct] is being asked for; null when nobody is. */ val toActToken: Long?, 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 } diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/PersonaArbiterTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/PersonaArbiterTest.kt new file mode 100644 index 0000000..b005e7a --- /dev/null +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/bot/PersonaArbiterTest.kt @@ -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 { 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))) + } +} diff --git a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt index 44c992d..2b82900 100644 --- a/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt +++ b/engine/src/commonTest/kotlin/com/jsjdesigns/poker/game/SnapshotAndHumanAgentTest.kt @@ -80,6 +80,21 @@ class SnapshotAndHumanAgentTest { seen.last().phase == TableSnapshot.Phase.COMPLETE, "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