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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user