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) :
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) {
/** 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. */
CAUTIOUS(bidThreshold = 11, aloneThreshold = 22),
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
}
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 {
var value = 0
var trumpCount = 0
for (card in hand) {
value += when {
isRightBower(card, trump) -> 9
isLeftBower(card, trump) -> 7
isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2
isRightBower(card, trump) -> 10
isLeftBower(card, trump) -> 8
isTrumpCard(card, trump) -> 4 + (card.rank.order - Rank.NINE.order) / 2
card.rank == Rank.ACE -> 3
card.rank == Rank.KING -> 1
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
}
@@ -70,6 +70,12 @@ data class EuchreState(
val turn: Int,
val passes: Int,
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 tricksWon: List<Int>,
/** 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,
turn = leader,
trick = emptyList(),
completedTrick = emptyList(),
passes = 0,
)
}
@@ -285,7 +286,8 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
val hands = state.hands.toMutableList()
hands[seat] = hands[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) {
return played.copy(turn = nextActive(state, seat))
@@ -300,6 +302,9 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
val afterTrick = played.copy(
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,
leader = winner,
turn = winner,
@@ -41,6 +41,12 @@ class MatchController<S : Any, M : Any>(
private val advanceIdle: (S) -> S? = { null },
/** Scaled by the animation-speed setting so the table paces itself. */
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 }
@@ -207,7 +213,7 @@ class MatchController<S : Any, M : Any>(
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
delay(aiThinkingMillis())
delay(holdBeforeNextMove(current) + aiThinkingMillis())
val chosen = lock.withLock {
val live = authoritative ?: return
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.settings.Settings
import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen
/**
@@ -58,10 +59,24 @@ fun PlayScreen(
}
},
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)
EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
EuchreScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
}
GameCatalog.SEQUENCE -> {
@@ -1,5 +1,7 @@
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.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -20,13 +23,22 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
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.Discard
import org.prolibertate.games.game.euchre.EuchreMove
@@ -53,6 +65,7 @@ import org.prolibertate.games.ui.theme.FeltGreenDark
fun EuchreScreen(
controller: MatchController<EuchreState, EuchreMove>,
localSeat: Int,
trickHoldMillis: Long,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
@@ -86,7 +99,7 @@ fun EuchreScreen(
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TrickArea(current, localSeat, cardWidth)
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
}
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
* 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
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)) {
// 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 = if (state.turn == localSeat) "Lead a card" else "Waiting…",
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 alignment = when (relative) {
0 -> Alignment.BottomCenter
@@ -213,7 +267,15 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
}
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,
) {
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.
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
val waitingOn = relativePosition(state.turn, localSeat)