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,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,
toActToken = if (toAct != null) pendingToken else null,
lastAction = events.lastOrNull(),
actionNumber = events.size,
)
private suspend fun emit(
@@ -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 }
@@ -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,
"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