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