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