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
@@ -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])
}
// -- 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