Fix the fold experience: honest actions, matched state, action identity
Reported as "folding looks broken". It was five separate defects.
1. Fold silently became Check. sanitise() rewrote FOLD to CHECK whenever
checking was free, so a UI showing a Fold button folded nothing and the
player kept being asked to act. Folding is legal at any turn — it simply
mucks — so FOLD is now honoured literally. The engine must never substitute a
different action than the caller asked for. The reverse rewrite (an illegal
CHECK facing a bet becoming FOLD) is legitimate and stays.
Safe by construction: no bot emits FOLD when it can check, and the 20k-hand
simulation reproduces byte-identical numbers (289.12 / 113.19 / -195.64).
2. Snapshot and offer could describe different moments. The 32-deep frame
channel let the engine race far ahead of the animation, so the action on
offer could belong to a later street, or another hand. The channel is now
RENDEZVOUS, capping the engine at one frame ahead, and UiState.liveOffer()
only surfaces an offer whose hand and street match the table on screen.
3. Stale and double taps could act on a later decision. DecisionOffer now
carries a token; submit() requires it and rejects anything stale, so a second
tap is dropped rather than applied to whatever comes next.
4. A real fold was invisible. The hero kept normal cards and no folded state, so
a correctly processed fold looked like a bug. Cards now dim, FOLDED shows in
red, and the action bar explains the player is sitting out.
5. Non-atomic UiState updates from two coroutines now use update {}.
Also: Fold is hidden when checking is free (folding a free hand is never
correct, and offering it invites an accidental muck), and onCleared no longer
calls human.cancel() — viewModelScope is already cancelled by then so the launch
never ran; scope cancellation already propagates into act()'s finally.
The delivery tests were weak as charged: no slow consumer, and not the app's
capacity. Replaced with a genuinely slow consumer measuring how far the engine
runs ahead — asserting <= 1 on RENDEZVOUS, and > 1 on a 32-deep buffer to
document why the buffer was removed.
Verified on the emulator (physical device untouched): folded facing a bet, hero
showed FOLDED, was never asked again that hand, and play advanced to hand 2.
Tests: 55 -> 62, green on jvmTest and testAndroidHostTest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,14 @@ package com.jsjdesigns.poker.game
|
||||
* is needed to render an action bar.
|
||||
*/
|
||||
data class DecisionOffer(
|
||||
/**
|
||||
* Identifies this specific decision.
|
||||
*
|
||||
* Submissions carry it back so a stale or double tap cannot be applied to a
|
||||
* later decision — a second tap landing after the turn moved on would
|
||||
* otherwise act on the next street, or even the next hand.
|
||||
*/
|
||||
val token: Long,
|
||||
val handNumber: Int,
|
||||
val street: Street,
|
||||
val seat: Int,
|
||||
@@ -26,7 +34,8 @@ data class DecisionOffer(
|
||||
val callAmount: Int get() = toCall
|
||||
}
|
||||
|
||||
fun DecisionContext.toOffer(): DecisionOffer = DecisionOffer(
|
||||
fun DecisionContext.toOffer(token: Long): DecisionOffer = DecisionOffer(
|
||||
token = token,
|
||||
handNumber = handNumber,
|
||||
street = street,
|
||||
seat = seat.index,
|
||||
|
||||
@@ -23,6 +23,8 @@ class HumanAgent : PlayerAgent {
|
||||
|
||||
private val lock = Mutex()
|
||||
private var pending: CompletableDeferred<Action>? = null
|
||||
private var pendingToken = 0L
|
||||
private var nextToken = 1L
|
||||
|
||||
private val _offer = MutableStateFlow<DecisionOffer?>(null)
|
||||
|
||||
@@ -33,13 +35,16 @@ class HumanAgent : PlayerAgent {
|
||||
|
||||
override suspend fun act(ctx: DecisionContext): Action {
|
||||
val deferred = CompletableDeferred<Action>()
|
||||
val token: Long
|
||||
lock.withLock {
|
||||
check(pending == null) { "already awaiting a decision for this agent" }
|
||||
pending = deferred
|
||||
token = nextToken++
|
||||
pendingToken = token
|
||||
}
|
||||
// Published after the deferred is installed, so a UI that reacts instantly
|
||||
// to the offer always finds something able to receive its submission.
|
||||
_offer.value = ctx.toOffer()
|
||||
_offer.value = ctx.toOffer(token)
|
||||
|
||||
return try {
|
||||
deferred.await()
|
||||
@@ -52,11 +57,16 @@ class HumanAgent : PlayerAgent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the player's choice. Returns false when nothing was waiting, which
|
||||
* makes a double-tap or a stale click harmless rather than a crash.
|
||||
* Supplies the player's choice for the decision identified by [token].
|
||||
*
|
||||
* Returns false when nothing is waiting or when [token] is stale. Requiring
|
||||
* the token is what stops a double tap, or a tap that lands just after the
|
||||
* turn moved on, from being applied to the *next* decision — which could be a
|
||||
* different street or an entirely different hand.
|
||||
*/
|
||||
suspend fun submit(action: Action): Boolean = lock.withLock {
|
||||
suspend fun submit(token: Long, action: Action): Boolean = lock.withLock {
|
||||
val deferred = pending ?: return@withLock false
|
||||
if (token != pendingToken) return@withLock false
|
||||
deferred.complete(action)
|
||||
}
|
||||
|
||||
|
||||
@@ -460,7 +460,11 @@ class Table(
|
||||
/** Clamps whatever an agent returns into something legal. */
|
||||
private fun sanitise(seat: Seat, toCall: Int, action: Action): Action {
|
||||
return when (action.type) {
|
||||
ActionType.FOLD -> if (toCall == 0) Action(ActionType.CHECK) else action
|
||||
// Folding is legal at any turn, including when checking is free — it
|
||||
// simply mucks. Rewriting it to CHECK was a lie to the caller: a UI
|
||||
// showing a Fold button would fold, and the player would keep getting
|
||||
// asked to act. Never silently substitute a different action.
|
||||
ActionType.FOLD -> action
|
||||
ActionType.CHECK -> if (toCall > 0) Action(ActionType.FOLD) else action
|
||||
ActionType.CALL -> if (toCall == 0) Action(ActionType.CHECK) else action
|
||||
ActionType.BET, ActionType.RAISE -> {
|
||||
|
||||
Reference in New Issue
Block a user