From 39a312b157d2f898431efa6e888610745cfd22ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:40:43 +0000 Subject: [PATCH] Stop the AI ordering up every deal; hold and sweep finished tricks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN --- .../games/game/euchre/EuchreAi.kt | 30 +++-- .../games/game/euchre/EuchreModel.kt | 6 + .../games/game/euchre/EuchreRules.kt | 7 +- .../prolibertate/games/net/MatchController.kt | 8 +- .../org/prolibertate/games/ui/PlayScreen.kt | 17 ++- .../games/ui/game/EuchreScreen.kt | 82 +++++++++++- .../game/euchre/EuchreBiddingRateTest.kt | 121 ++++++++++++++++++ .../games/game/euchre/EuchreRulesTest.kt | 39 ++++++ 8 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt index ea8f6ee..52cd6da 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt @@ -16,14 +16,20 @@ import org.prolibertate.games.game.engine.GameAi class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : GameAi { + /** + * 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 { @@ -47,19 +53,25 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : */ private fun handValue(hand: List, 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 } diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt index 3087724..c55abc3 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt @@ -70,6 +70,12 @@ data class EuchreState( val turn: Int, val passes: Int, val trick: List, + /** + * 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 = emptyList(), val leader: Int, val tricksWon: List, /** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */ diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt index 3c7084a..3782f95 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt @@ -277,6 +277,7 @@ object EuchreRules : GameRules { leader = leader, turn = leader, trick = emptyList(), + completedTrick = emptyList(), passes = 0, ) } @@ -285,7 +286,8 @@ object EuchreRules : GameRules { 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 { 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, diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 1ed9c2e..7a1bc86 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -41,6 +41,12 @@ class MatchController( 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( 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 diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index aae16d7..770ec79 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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 -> { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt index ed64229..3165a39 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -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, 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) diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt new file mode 100644 index 0000000..3dd7105 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreBiddingRateTest.kt @@ -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) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt index 56b7e50..00d123f 100644 --- a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt @@ -304,6 +304,45 @@ class EuchreRulesTest { 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 --------------------------------------------------------- @Test