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