Stop the AI ordering up every deal; hold and sweep finished tricks

The real cause of being forced to pick up the turn card was the AI, not
the Pass button. Measured over 2000 deals, the computer ordered up on
100% of them and the dealer was dragged into picking up on 97.2% —
bidding never once reached the second round, so a human dealer never got
a say. Average hand scored about 11 against a bid threshold of 9, so
essentially every hand bid.

- Voids no longer count towards a hand unless there is trump left to ruff
  with, which was inflating precisely the weak hands that should pass.
- Thresholds recalibrated against the actual score distribution.
- EuchreBiddingRateTest pins the result so this cannot drift back: now
  32.8% ordered up in round one, dealer forced to pick up on 12.8%, and
  round two reached on 67%.

Finished tricks are now held and swept, for this and any later
trick-taking game:

- The engine kept a trick only until it resolved, so the fourth card was
  never visible — it vanished the instant it landed. Completed tricks are
  now retained in state until the next card is played.
- The table holds them for a second, names the winner, then slides all
  four towards that player's edge and fades them out. The hold scales
  with the animation speed setting.
- MatchController gains a general holdBeforeNextMove hook so the AI waits
  out the animation rather than playing over it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 01:40:43 +00:00
parent 76c136718d
commit 39a312b157
8 changed files with 293 additions and 17 deletions
@@ -16,14 +16,20 @@ import org.prolibertate.games.game.engine.GameAi
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<EuchreState, EuchreMove> { GameAi<EuchreState, EuchreMove> {
/**
* Thresholds are calibrated against the score an average five-card hand
* earns from [handValue], which sits around 11. A threshold near that mean
* means every seat bids and the card is never turned down — see
* EuchreBiddingRateTest, which pins the resulting order-up rate.
*/
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) { enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
/** Bids loosely and plays straightforwardly. */ /** Bids loosely and plays straightforwardly. */
EASY(bidThreshold = 7, aloneThreshold = 26), EASY(bidThreshold = 21, aloneThreshold = 32),
NORMAL(bidThreshold = 9, aloneThreshold = 24), NORMAL(bidThreshold = 24, aloneThreshold = 32),
/** Bids tighter and is harder to sneak a trick past. */ /** Bids tighter and is harder to sneak a trick past. */
CAUTIOUS(bidThreshold = 11, aloneThreshold = 22), CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
} }
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove { override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
@@ -47,19 +53,25 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
*/ */
private fun handValue(hand: List<Card>, trump: Suit): Int { private fun handValue(hand: List<Card>, trump: Suit): Int {
var value = 0 var value = 0
var trumpCount = 0
for (card in hand) { for (card in hand) {
value += when { value += when {
isRightBower(card, trump) -> 9 isRightBower(card, trump) -> 10
isLeftBower(card, trump) -> 7 isLeftBower(card, trump) -> 8
isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2 isTrumpCard(card, trump) -> 4 + (card.rank.order - Rank.NINE.order) / 2
card.rank == Rank.ACE -> 3 card.rank == Rank.ACE -> 3
card.rank == Rank.KING -> 1 card.rank == Rank.KING -> 1
else -> 0 else -> 0
} }
if (isTrumpCard(card, trump)) trumpCount++
}
// A void is a chance to trump in — but only while there is trump left
// to do it with. Counting voids on a trumpless hand was inflating
// exactly the weak hands that should be passing.
if (trumpCount >= 2) {
val sideSuits = Suit.entries.filter { it != trump }
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
} }
// A void in a side suit means an early chance to trump in.
val sideSuits = Suit.entries.filter { it != trump }
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
return value return value
} }
@@ -70,6 +70,12 @@ data class EuchreState(
val turn: Int, val turn: Int,
val passes: Int, val passes: Int,
val trick: List<PlayedCard>, val trick: List<PlayedCard>,
/**
* The trick that has just been won, kept on the table so it can be read and
* then animated away. Cleared as soon as the next card is played. Winner is
* [lastTrickWinner].
*/
val completedTrick: List<PlayedCard> = emptyList(),
val leader: Int, val leader: Int,
val tricksWon: List<Int>, val tricksWon: List<Int>,
/** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */ /** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */
@@ -277,6 +277,7 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
leader = leader, leader = leader,
turn = leader, turn = leader,
trick = emptyList(), trick = emptyList(),
completedTrick = emptyList(),
passes = 0, passes = 0,
) )
} }
@@ -285,7 +286,8 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
val hands = state.hands.toMutableList() val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - card hands[seat] = hands[seat] - card
val trick = state.trick + PlayedCard(seat, card) val trick = state.trick + PlayedCard(seat, card)
val played = state.copy(hands = hands, trick = trick) // Playing to a new trick sweeps the previous one off the table.
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
if (trick.size < state.activeSeatCount) { if (trick.size < state.activeSeatCount) {
return played.copy(turn = nextActive(state, seat)) return played.copy(turn = nextActive(state, seat))
@@ -300,6 +302,9 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
val afterTrick = played.copy( val afterTrick = played.copy(
trick = emptyList(), trick = emptyList(),
// Held so the finished trick can be seen before it is cleared away,
// rather than the fourth card vanishing the instant it lands.
completedTrick = trick,
tricksWon = tricksWon, tricksWon = tricksWon,
leader = winner, leader = winner,
turn = winner, turn = winner,
@@ -41,6 +41,12 @@ class MatchController<S : Any, M : Any>(
private val advanceIdle: (S) -> S? = { null }, private val advanceIdle: (S) -> S? = { null },
/** Scaled by the animation-speed setting so the table paces itself. */ /** Scaled by the animation-speed setting so the table paces itself. */
private val aiThinkingMillis: () -> Long = { 700L }, private val aiThinkingMillis: () -> Long = { 700L },
/**
* Extra pause before the next move is made, given the current state. Lets a
* game hold a finished trick on the table long enough to be read before the
* next card lands on top of it.
*/
private val holdBeforeNextMove: (S) -> Long = { 0L },
) { ) {
enum class Role { HOST, CLIENT } enum class Role { HOST, CLIENT }
@@ -207,7 +213,7 @@ class MatchController<S : Any, M : Any>(
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
delay(aiThinkingMillis()) delay(holdBeforeNextMove(current) + aiThinkingMillis())
val chosen = lock.withLock { val chosen = lock.withLock {
val live = authoritative ?: return val live = authoritative ?: return
if (rules.currentSeat(live) != seat) return@withLock null if (rules.currentSeat(live) != seat) return@withLock null
@@ -19,6 +19,7 @@ import org.prolibertate.games.game.sequence.SequenceState
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen import org.prolibertate.games.ui.game.SequenceScreen
/** /**
@@ -58,10 +59,24 @@ fun PlayScreen(
} }
}, },
aiThinkingMillis = { settings.scaled(700L) }, aiThinkingMillis = { settings.scaled(700L) },
// Keep the finished trick on the table before the next
// card lands on top of it.
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
) )
} }
StartMatch(env, controller, route) StartMatch(env, controller, route)
EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) EuchreScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
} }
GameCatalog.SEQUENCE -> { GameCatalog.SEQUENCE -> {
@@ -1,5 +1,7 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -20,13 +23,22 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
import org.prolibertate.games.game.euchre.CallTrump import org.prolibertate.games.game.euchre.CallTrump
import org.prolibertate.games.game.euchre.Discard import org.prolibertate.games.game.euchre.Discard
import org.prolibertate.games.game.euchre.EuchreMove import org.prolibertate.games.game.euchre.EuchreMove
@@ -53,6 +65,7 @@ import org.prolibertate.games.ui.theme.FeltGreenDark
fun EuchreScreen( fun EuchreScreen(
controller: MatchController<EuchreState, EuchreMove>, controller: MatchController<EuchreState, EuchreMove>,
localSeat: Int, localSeat: Int,
trickHoldMillis: Long,
onExit: () -> Unit, onExit: () -> Unit,
) { ) {
val state by controller.state.collectAsState() val state by controller.state.collectAsState()
@@ -86,7 +99,7 @@ fun EuchreScreen(
.background(FeltGreenDark), .background(FeltGreenDark),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
TrickArea(current, localSeat, cardWidth) TrickArea(current, localSeat, cardWidth, trickHoldMillis)
} }
notice?.let { notice?.let {
@@ -168,8 +181,47 @@ private fun seatLabel(relative: Int): String = when (relative) {
* turned to face them, so you can read a trick at a glance instead of matching * turned to face them, so you can read a trick at a glance instead of matching
* seat numbers to cards. * seat numbers to cards.
*/ */
/** How long a completed trick rests on the table before being swept away. */
const val TRICK_HOLD_MILLIS = 1_000L
/** Duration of the sweep itself. */
private const val TRICK_SWEEP_MILLIS = 450
@Composable @Composable
private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) { private fun TrickArea(
state: EuchreState,
localSeat: Int,
cardWidth: Dp,
trickHoldMillis: Long,
) {
// A finished trick stays put for a beat so it can be read, then slides off
// towards whoever won it.
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
LaunchedEffect(state.completedTrick) {
if (state.completedTrick.isNotEmpty()) {
delay(trickHoldMillis)
sweepStarted = true
}
}
val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS),
label = "trickSweep",
)
// Everything travels towards the winner's edge of the table together.
val winnerRelative = state.lastTrickWinner?.let { relativePosition(it, localSeat) } ?: 0
val (dirX, dirY) = when (winnerRelative) {
0 -> 0f to 1f
1 -> -1f to 0f
2 -> 0f to -1f
else -> 1f to 0f
}
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) { Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// The turn card sits in the middle of the table during bidding. // The turn card sits in the middle of the table during bidding.
@@ -187,7 +239,9 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
} }
} }
if (state.upCard == null && state.trick.isEmpty() && state.phase == EuchrePhase.PLAYING) { if (state.upCard == null && cardsOnTable.isEmpty() &&
state.phase == EuchrePhase.PLAYING
) {
Text( Text(
text = if (state.turn == localSeat) "Lead a card" else "Waiting…", text = if (state.turn == localSeat) "Lead a card" else "Waiting…",
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
@@ -195,7 +249,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
) )
} }
state.trick.forEach { played -> cardsOnTable.forEach { played ->
val relative = relativePosition(played.seat, localSeat) val relative = relativePosition(played.seat, localSeat)
val alignment = when (relative) { val alignment = when (relative) {
0 -> Alignment.BottomCenter 0 -> Alignment.BottomCenter
@@ -213,7 +267,15 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
} }
Column( Column(
modifier = Modifier.align(alignment), modifier = Modifier
.align(alignment)
.offset {
IntOffset(
x = (dirX * travelPx * sweep).roundToInt(),
y = (dirY * travelPx * sweep).roundToInt(),
)
}
.alpha(1f - sweep),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
PlayingCardView( PlayingCardView(
@@ -229,6 +291,16 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
} }
} }
// Name the winner while their cards are still on the table.
if (sweeping && state.lastTrickWinner != null) {
Text(
text = "${seatLabel(winnerRelative)} takes it",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
)
}
// Who is on the clock, shown where they sit rather than as a seat number. // Who is on the clock, shown where they sit rather than as a seat number.
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) { if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
val waitingOn = relativePosition(state.turn, localSeat) val waitingOn = relativePosition(state.turn, localSeat)
@@ -0,0 +1,121 @@
package org.prolibertate.games.game.euchre
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* How often the AI takes the bid.
*
* This exists because an over-eager AI does not look like a tuning problem from
* the table — it looks like the game refusing to let you bid. If the three
* computer players order up nearly every deal, a human dealer is dragged into
* picking up the turn card again and again and never gets a say.
*/
class EuchreBiddingRateTest {
private val json = Json { encodeDefaults = true }
private fun config(seed: Long, options: EuchreOptions = EuchreOptions()) = TableConfig(
gameId = "euchre",
seats = (0 until 4).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private data class Outcome(
val orderedUpInRoundOne: Boolean,
val reachedRoundTwo: Boolean,
val dealerForcedToPickUp: Boolean,
)
/** Runs the bidding for one deal with every seat played by the AI. */
private fun bidOutDeal(seed: Long): Outcome {
val ai = EuchreAi()
var state = EuchreRules.initialState(config(seed))
val dealer = state.dealer
var reachedRoundTwo = false
var guard = 0
while (guard++ < 40) {
when (state.phase) {
EuchrePhase.BID_ROUND_1 -> Unit
EuchrePhase.BID_ROUND_2 -> reachedRoundTwo = true
else -> break
}
val seat = EuchreRules.currentSeat(state) ?: break
val legal = EuchreRules.legalMoves(state, seat)
state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
// Reaching the discard step means somebody ordered it up and the dealer
// now has to take the card whether they like it or not.
val orderedUpInRoundOne = !reachedRoundTwo && state.trump != null
return Outcome(
orderedUpInRoundOne = orderedUpInRoundOne,
reachedRoundTwo = reachedRoundTwo,
dealerForcedToPickUp = orderedUpInRoundOne && state.maker != dealer,
)
}
@Test
fun `the ai does not order up on most deals`() {
val deals = 2000
val outcomes = (0 until deals).map { bidOutDeal(it.toLong()) }
val orderedUp = outcomes.count { it.orderedUpInRoundOne }
val forcedPickUp = outcomes.count { it.dealerForcedToPickUp }
val roundTwo = outcomes.count { it.reachedRoundTwo }
val orderedUpRate = orderedUp * 100.0 / deals
val forcedRate = forcedPickUp * 100.0 / deals
println(
"ordered up in round 1: %.1f%% dealer forced to pick up: %.1f%% reached round 2: %.1f%%"
.format(orderedUpRate, forcedRate, roundTwo * 100.0 / deals)
)
// Ordering up should be a decision, not a formality. Real play turns the
// card down more often than not, so a majority of deals must survive
// round one.
assertTrue(
"AI orders up on %.1f%% of deals — far too eager".format(orderedUpRate),
orderedUpRate <= 55.0,
)
assertTrue(
"AI almost never bids (%.1f%%) — too timid".format(orderedUpRate),
orderedUpRate >= 15.0,
)
// The specific complaint: as dealer you keep being handed the turn card.
assertTrue(
"dealer is dragged into picking up on %.1f%% of deals".format(forcedRate),
forcedRate <= 45.0,
)
}
@Test
fun `going alone stays rare`() {
val ai = EuchreAi()
var alone = 0
val deals = 1000
repeat(deals) { seed ->
var state = EuchreRules.initialState(config(seed.toLong()))
var guard = 0
while (state.trump == null && guard++ < 40) {
val seat = EuchreRules.currentSeat(state) ?: break
val legal = EuchreRules.legalMoves(state, seat)
if (legal.isEmpty()) break
state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
if (state.aloneSeat != null) alone++
}
val rate = alone * 100.0 / deals
println("went alone: %.1f%%".format(rate))
assertTrue("going alone on %.1f%% of deals is too often".format(rate), rate <= 12.0)
}
}
@@ -304,6 +304,45 @@ class EuchreRulesTest {
assertEquals(0, finished.scores[0]) assertEquals(0, finished.scores[0])
} }
// -- Completed tricks ---------------------------------------------------
@Test
fun `a finished trick stays on the table until the next card is played`() {
val trump = Suit.SPADES
val hands = listOf(
listOf(Card(Rank.ACE, Suit.SPADES), Card(Rank.NINE, Suit.HEARTS)),
listOf(Card(Rank.NINE, Suit.SPADES), Card(Rank.TEN, Suit.HEARTS)),
listOf(Card(Rank.TEN, Suit.SPADES), Card(Rank.QUEEN, Suit.HEARTS)),
listOf(Card(Rank.QUEEN, Suit.SPADES), Card(Rank.KING, Suit.HEARTS)),
)
var state = playingState(hands = hands, trump = trump)
// Play a full trick.
repeat(4) {
val seat = EuchreRules.currentSeat(state)!!
state = EuchreRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue("the live trick is cleared", state.trick.isEmpty())
assertEquals("all four cards are held for display", 4, state.completedTrick.size)
assertEquals("seat 0 played the ace of trump", 0, state.lastTrickWinner)
// The next card played sweeps it away.
val next = EuchreRules.currentSeat(state)!!
state = EuchreRules.applyMove(state, next, PlayCard(state.hands[next].first()))
assertTrue("held trick is cleared once play resumes", state.completedTrick.isEmpty())
}
@Test
fun `a fresh hand starts with nothing held on the table`() {
var state = EuchreRules.initialState(config())
assertTrue(state.completedTrick.isEmpty())
state = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false))
val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard
state = EuchreRules.applyMove(state, state.dealer, discard)
assertTrue("play begins with a clear table", state.completedTrick.isEmpty())
}
// -- Full games --------------------------------------------------------- // -- Full games ---------------------------------------------------------
@Test @Test