Add Kaiser, Crazy 8s and Wizard
Three more card games, engine to screen. Kaiser: 32-card prairie trick-taking with bidding, the five of hearts worth five and the three of spades costing three. Bidding is capped at ten because ten is all a hand can be worth, and a losing score gives the game a floor — without one a table that keeps bidding and going down never reaches a result. Crazy 8s: eights are wild and name the suit in force, which is why the table states that suit in words rather than leaving it to be read off the pile. Blocked rounds are scored rather than hanging. Wizard: the 60-card deck, one more card dealt each round until the pack runs out, bid your tricks exactly. The shared card model gains WIZARD and JESTER ranks, so every ordinary deck now builds from Rank.standard rather than walking the whole enum. Also fixes a lobby bug this would have hit: hosting seated the catalogue minimum rather than the table the host had just set up, so a six-handed game would have been rejected by its own rules engine. 164 engine tests pass locally.
This commit is contained in:
@@ -58,7 +58,7 @@ object GameCatalog {
|
||||
maxPlayers = 4,
|
||||
teamBased = true,
|
||||
blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = PRESIDENT,
|
||||
@@ -88,7 +88,7 @@ object GameCatalog {
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CRAZY_EIGHTS,
|
||||
@@ -98,7 +98,7 @@ object GameCatalog {
|
||||
maxPlayers = 6,
|
||||
teamBased = false,
|
||||
blurb = "Match rank or suit, and eights change everything.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = SEQUENCE,
|
||||
|
||||
@@ -19,8 +19,14 @@ enum class Suit(val symbol: String, val isRed: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [standard] marks the fifty-two cards every ordinary deck holds. The two
|
||||
* extras belong to Wizard alone, so anything building a normal deck must filter
|
||||
* on this rather than walking the whole enum — otherwise every other game
|
||||
* quietly gains eight cards it has no rules for.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Rank(val short: String, val order: Int) {
|
||||
enum class Rank(val short: String, val order: Int, val standard: Boolean = true) {
|
||||
TWO("2", 2),
|
||||
THREE("3", 3),
|
||||
FOUR("4", 4),
|
||||
@@ -34,6 +40,18 @@ enum class Rank(val short: String, val order: Int) {
|
||||
QUEEN("Q", 12),
|
||||
KING("K", 13),
|
||||
ACE("A", 14),
|
||||
|
||||
/** Wizard only: beats everything, including trump. */
|
||||
WIZARD("Wz", 100, standard = false),
|
||||
|
||||
/** Wizard only: loses to everything, and leads no suit. */
|
||||
JESTER("Je", 0, standard = false),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** The thirteen ranks of an ordinary deck. */
|
||||
val standard: List<Rank> get() = entries.filter { it.standard }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -61,7 +79,7 @@ object Decks {
|
||||
|
||||
/** Standard 52-card deck. */
|
||||
fun standard52(): List<Card> =
|
||||
Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } }
|
||||
Suit.entries.flatMap { suit -> Rank.standard.map { rank -> Card(rank, suit) } }
|
||||
|
||||
/**
|
||||
* Euchre decks. 24 cards (9 through ace) is the common game; 32 cards
|
||||
@@ -74,12 +92,38 @@ object Decks {
|
||||
else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size")
|
||||
}
|
||||
return Suit.entries.flatMap { suit ->
|
||||
Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
|
||||
Rank.standard.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 60-card Wizard deck: an ordinary pack plus four wizards and four
|
||||
* jesters, one of each per suit so they fit the same card model.
|
||||
*/
|
||||
fun wizard(): List<Card> = standard52() +
|
||||
Suit.entries.map { Card(Rank.WIZARD, it) } +
|
||||
Suit.entries.map { Card(Rank.JESTER, it) }
|
||||
|
||||
/** Two full decks shuffled together, as Sequence uses. */
|
||||
fun double52(): List<Card> = standard52() + standard52()
|
||||
|
||||
/**
|
||||
* The 32-card Kaiser deck: eight to ace in every suit, plus the seven of
|
||||
* clubs and the seven of diamonds — and, in place of the other two sevens,
|
||||
* the five of hearts and the three of spades, which are what the whole game
|
||||
* is played for.
|
||||
*/
|
||||
fun kaiser(): List<Card> {
|
||||
val high = Suit.entries.flatMap { suit ->
|
||||
Rank.standard.filter { it.order >= Rank.EIGHT.order }.map { Card(it, suit) }
|
||||
}
|
||||
return high + listOf(
|
||||
Card(Rank.SEVEN, Suit.CLUBS),
|
||||
Card(Rank.SEVEN, Suit.DIAMONDS),
|
||||
Card(Rank.FIVE, Suit.HEARTS),
|
||||
Card(Rank.THREE, Suit.SPADES),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.prolibertate.games.game.crazyeights
|
||||
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Crazy 8s opponent.
|
||||
*
|
||||
* It sheds expensive cards first, keeps its eights back for when it is stuck,
|
||||
* and when it does play one it names the suit it holds most of.
|
||||
*/
|
||||
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
GameAi<CrazyEightsState, CrazyEightsMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** Play an eight anyway once the hand is down to this size. */
|
||||
val eightEndgame: Int,
|
||||
) {
|
||||
EASY(eightEndgame = 99),
|
||||
NORMAL(eightEndgame = 3),
|
||||
CAUTIOUS(eightEndgame = 2),
|
||||
}
|
||||
|
||||
override fun chooseMove(
|
||||
state: CrazyEightsState,
|
||||
seat: Int,
|
||||
legal: List<CrazyEightsMove>,
|
||||
): CrazyEightsMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val plays = legal.filterIsInstance<PlayCard>()
|
||||
if (plays.isEmpty()) return legal.first()
|
||||
|
||||
val hand = state.hands[seat]
|
||||
val ordinary = plays.filter { !isWild(it.card) }
|
||||
|
||||
// An eight is the only card that always plays, so it is worth more held
|
||||
// than spent — unless the hand is nearly gone, or nothing else works.
|
||||
val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame
|
||||
val pool = if (useEight) plays else ordinary
|
||||
|
||||
val best = pool
|
||||
.filter { !isWild(it.card) }
|
||||
.maxByOrNull { crazyEightsPenalty(it.card) }
|
||||
|
||||
if (best != null) return best
|
||||
|
||||
// Playing an eight: switch the game to whatever we hold most of.
|
||||
val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } }
|
||||
?: Suit.SPADES
|
||||
return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.prolibertate.games.game.crazyeights
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
|
||||
@Serializable
|
||||
data class CrazyEightsOptions(
|
||||
val playerCount: Int = 4,
|
||||
/** Zero means the usual count: seven for two players, five otherwise. */
|
||||
val startingHand: Int = 0,
|
||||
/** Keep drawing until something is playable, rather than drawing one and passing. */
|
||||
val drawUntilPlayable: Boolean = true,
|
||||
val roundsToPlay: Int = 3,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 2..6) { "Crazy 8s seats two to six" }
|
||||
require(roundsToPlay >= 1) { "roundsToPlay must be positive" }
|
||||
}
|
||||
|
||||
fun handSize(): Int = if (startingHand > 0) startingHand else if (playerCount == 2) 7 else 5
|
||||
}
|
||||
|
||||
/** Eights are wild and expensive; pictures cost ten; everything else is face value. */
|
||||
fun crazyEightsPenalty(card: Card): Int = when (card.rank) {
|
||||
Rank.EIGHT -> 50
|
||||
Rank.KING, Rank.QUEEN, Rank.JACK, Rank.TEN -> 10
|
||||
Rank.ACE -> 1
|
||||
else -> card.rank.order
|
||||
}
|
||||
|
||||
fun isWild(card: Card): Boolean = card.rank == Rank.EIGHT
|
||||
|
||||
@Serializable
|
||||
enum class CrazyEightsPhase { PLAYING, ROUND_OVER, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class CrazyEightsState(
|
||||
val options: CrazyEightsOptions,
|
||||
val seed: Long,
|
||||
val roundNumber: Int,
|
||||
val hands: List<List<Card>>,
|
||||
val handCounts: List<Int>,
|
||||
val stock: List<Card>,
|
||||
val discard: List<Card>,
|
||||
/**
|
||||
* The suit that must be followed. Usually the top card's suit, but an eight
|
||||
* is wild and its player names this instead — so it cannot be read off the
|
||||
* discard pile.
|
||||
*/
|
||||
val suitInForce: Suit,
|
||||
val turn: Int,
|
||||
/** Cards drawn so far this turn, which limits how much more may be drawn. */
|
||||
val drawnThisTurn: Int,
|
||||
/** Passes in a row. Reaching the player count means the round is blocked. */
|
||||
val consecutivePasses: Int = 0,
|
||||
val scores: List<Int>,
|
||||
val roundWinner: Int?,
|
||||
val phase: CrazyEightsPhase,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val topCard: Card? get() = discard.lastOrNull()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface CrazyEightsMove
|
||||
|
||||
/** [nominatedSuit] is set only when playing an eight. */
|
||||
@Serializable
|
||||
@SerialName("play")
|
||||
data class PlayCard(val card: Card, val nominatedSuit: Suit? = null) : CrazyEightsMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("draw")
|
||||
data object DrawCard : CrazyEightsMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("pass")
|
||||
data object PassTurn : CrazyEightsMove
|
||||
|
||||
/** Whether [card] may be played on a pile showing [top] with [suitInForce]. */
|
||||
fun canPlay(card: Card, top: Card?, suitInForce: Suit): Boolean = when {
|
||||
isWild(card) -> true
|
||||
top == null -> true
|
||||
card.suit == suitInForce -> true
|
||||
card.rank == top.rank -> true
|
||||
else -> false
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package org.prolibertate.games.game.crazyeights
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Crazy 8s as a pure state machine. See RULES-crazy8s.md.
|
||||
*/
|
||||
object CrazyEightsRules : GameRules<CrazyEightsState, CrazyEightsMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.CRAZY_EIGHTS
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): CrazyEightsState {
|
||||
val options = json.decodeFromString<CrazyEightsOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: CrazyEightsOptions,
|
||||
seed: Long,
|
||||
roundNumber: Int,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): CrazyEightsState {
|
||||
val random = Random(seed + roundNumber * 7919L)
|
||||
var deck = Decks.standard52().shuffledWith(random)
|
||||
|
||||
val hands = mutableListOf<List<Card>>()
|
||||
repeat(options.playerCount) {
|
||||
hands += deck.take(options.handSize())
|
||||
deck = deck.drop(options.handSize())
|
||||
}
|
||||
|
||||
// An eight on top at the start would leave nobody having named a suit,
|
||||
// so the first upcard is taken from further down if need be.
|
||||
val firstIndex = deck.indexOfFirst { !isWild(it) }.takeIf { it >= 0 } ?: 0
|
||||
val upCard = deck[firstIndex]
|
||||
val stock = deck.filterIndexed { index, _ -> index != firstIndex }
|
||||
|
||||
return CrazyEightsState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
roundNumber = roundNumber,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
stock = stock,
|
||||
discard = listOf(upCard),
|
||||
suitInForce = upCard.suit,
|
||||
turn = roundNumber % options.playerCount,
|
||||
drawnThisTurn = 0,
|
||||
consecutivePasses = 0,
|
||||
scores = scores,
|
||||
roundWinner = null,
|
||||
phase = CrazyEightsPhase.PLAYING,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
|
||||
/** Advances from [CrazyEightsPhase.ROUND_OVER] into the next deal. */
|
||||
fun nextRound(state: CrazyEightsState): CrazyEightsState {
|
||||
check(state.phase == CrazyEightsPhase.ROUND_OVER) { "Round is not over" }
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
roundNumber = state.roundNumber + 1,
|
||||
scores = state.scores,
|
||||
log = state.log + "Round ${state.roundNumber + 2}.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: CrazyEightsState): Int? =
|
||||
if (state.phase == CrazyEightsPhase.PLAYING) state.turn else null
|
||||
|
||||
override fun legalMoves(state: CrazyEightsState, seat: Int): List<CrazyEightsMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
val hand = state.hands[seat]
|
||||
val playable = hand.filter { canPlay(it, state.topCard, state.suitInForce) }
|
||||
|
||||
if (playable.isNotEmpty()) {
|
||||
return playable.flatMap { card ->
|
||||
if (isWild(card)) {
|
||||
// Playing an eight is really four different moves: the card
|
||||
// plus the suit it turns the game to.
|
||||
Suit.entries.map { PlayCard(card, it) }
|
||||
} else {
|
||||
listOf(PlayCard(card))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to play. There is something to draw as long as the stock has
|
||||
// a card or the discards can be recycled into one — the pile in play
|
||||
// does not count, since it stays put.
|
||||
val somethingToDraw = state.stock.isNotEmpty() || state.discard.size > 1
|
||||
val mayDraw = somethingToDraw &&
|
||||
(state.options.drawUntilPlayable || state.drawnThisTurn == 0)
|
||||
return if (mayDraw) listOf(DrawCard) else listOf(PassTurn)
|
||||
}
|
||||
|
||||
override fun applyMove(
|
||||
state: CrazyEightsState,
|
||||
seat: Int,
|
||||
move: CrazyEightsMove,
|
||||
): CrazyEightsState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is PlayCard -> applyPlay(state, seat, move)
|
||||
is DrawCard -> applyDraw(state, seat)
|
||||
is PassTurn -> applyPass(state, seat)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPlay(
|
||||
state: CrazyEightsState,
|
||||
seat: Int,
|
||||
move: PlayCard,
|
||||
): CrazyEightsState {
|
||||
val hands = state.hands.toMutableList()
|
||||
hands[seat] = hands[seat] - move.card
|
||||
|
||||
val suit = if (isWild(move.card)) {
|
||||
requireNotNull(move.nominatedSuit) { "An eight must name a suit" }
|
||||
} else {
|
||||
move.card.suit
|
||||
}
|
||||
|
||||
val played = state.copy(
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
discard = state.discard + move.card,
|
||||
suitInForce = suit,
|
||||
drawnThisTurn = 0,
|
||||
consecutivePasses = 0,
|
||||
log = state.log + buildString {
|
||||
append("Seat $seat plays ${move.card.label}")
|
||||
if (isWild(move.card)) append(" and calls ${suit.symbol}")
|
||||
append(".")
|
||||
},
|
||||
)
|
||||
|
||||
if (hands[seat].isEmpty()) return scoreRound(played, seat)
|
||||
return advance(played, seat)
|
||||
}
|
||||
|
||||
private fun applyDraw(state: CrazyEightsState, seat: Int): CrazyEightsState {
|
||||
var stock = state.stock
|
||||
var discard = state.discard
|
||||
|
||||
if (stock.isEmpty()) {
|
||||
// Recycle everything but the card in play.
|
||||
if (discard.size <= 1) return advance(state, seat)
|
||||
val top = discard.last()
|
||||
stock = discard.dropLast(1).shuffledWith(Random(state.seed + state.log.size))
|
||||
discard = listOf(top)
|
||||
}
|
||||
|
||||
val hands = state.hands.toMutableList()
|
||||
hands[seat] = hands[seat] + stock.first()
|
||||
|
||||
return state.copy(
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
stock = stock.drop(1),
|
||||
discard = discard,
|
||||
drawnThisTurn = state.drawnThisTurn + 1,
|
||||
consecutivePasses = 0,
|
||||
log = state.log + "Seat $seat draws.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone passing in succession means nobody can move and the deck is
|
||||
* spent. The round is blocked: it ends there and the smallest hand takes it.
|
||||
*/
|
||||
private fun applyPass(state: CrazyEightsState, seat: Int): CrazyEightsState {
|
||||
val passes = state.consecutivePasses + 1
|
||||
val passed = state.copy(
|
||||
consecutivePasses = passes,
|
||||
log = state.log + "Seat $seat passes.",
|
||||
)
|
||||
if (passes >= state.options.playerCount) {
|
||||
val smallest = passed.hands.indices
|
||||
.minByOrNull { passed.hands[it].sumOf { card -> crazyEightsPenalty(card) } }
|
||||
return scoreRound(
|
||||
passed.copy(log = passed.log + "Nobody can move — the round is blocked."),
|
||||
smallest,
|
||||
)
|
||||
}
|
||||
return advance(passed, seat)
|
||||
}
|
||||
|
||||
private fun advance(state: CrazyEightsState, seat: Int): CrazyEightsState = state.copy(
|
||||
turn = (seat + 1) % state.options.playerCount,
|
||||
drawnThisTurn = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* The player who went out scores nothing; everyone else is charged for what
|
||||
* they are still holding.
|
||||
*/
|
||||
private fun scoreRound(state: CrazyEightsState, winner: Int?): CrazyEightsState {
|
||||
val scores = state.scores.mapIndexed { seat, total ->
|
||||
total + state.hands[seat].sumOf { crazyEightsPenalty(it) }
|
||||
}
|
||||
val lastRound = state.roundNumber + 1 >= state.options.roundsToPlay
|
||||
return state.copy(
|
||||
scores = scores,
|
||||
roundWinner = winner,
|
||||
phase = if (lastRound) CrazyEightsPhase.GAME_OVER else CrazyEightsPhase.ROUND_OVER,
|
||||
log = state.log + if (winner != null) "Seat $winner goes out." else "Round ends.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: CrazyEightsState): Boolean =
|
||||
state.phase == CrazyEightsPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: CrazyEightsState): String = when (state.phase) {
|
||||
CrazyEightsPhase.GAME_OVER -> {
|
||||
val best = state.scores.indices.minByOrNull { state.scores[it] } ?: 0
|
||||
"Seat $best wins on ${state.scores[best]}"
|
||||
}
|
||||
|
||||
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun viewFor(state: CrazyEightsState, seat: Int): CrazyEightsState = state.copy(
|
||||
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||
handCounts = state.handCounts,
|
||||
stock = emptyList(),
|
||||
)
|
||||
|
||||
override fun encodeState(state: CrazyEightsState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): CrazyEightsState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: CrazyEightsMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): CrazyEightsMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.prolibertate.games.game.kaiser
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Kaiser opponent.
|
||||
*
|
||||
* It rates a hand by its best suit, bids only when it can see the points, and
|
||||
* in play chases the five of hearts while trying not to be the one holding the
|
||||
* trick the three of spades lands in.
|
||||
*/
|
||||
class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
GameAi<KaiserState, KaiserMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** Hand strength needed before bidding above the floor. */
|
||||
val bidThreshold: Int,
|
||||
) {
|
||||
EASY(bidThreshold = 12),
|
||||
NORMAL(bidThreshold = 16),
|
||||
CAUTIOUS(bidThreshold = 19),
|
||||
}
|
||||
|
||||
override fun chooseMove(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
KaiserPhase.BIDDING -> chooseBid(state, seat, legal)
|
||||
KaiserPhase.PLAYING -> choosePlay(state, seat, legal)
|
||||
else -> legal.first()
|
||||
}
|
||||
}
|
||||
|
||||
/** Trump length and top cards, plus a nod to holding the counters. */
|
||||
private fun suitValue(hand: List<Card>, trump: Suit): Int {
|
||||
var value = hand.count { it.suit == trump } * 3
|
||||
for (card in hand) {
|
||||
value += when {
|
||||
card.suit == trump && card.rank == Rank.ACE -> 4
|
||||
card.suit == trump && card.rank == Rank.KING -> 3
|
||||
card == FIVE_OF_HEARTS -> 3
|
||||
card.rank == Rank.ACE -> 2
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
// Being long in spades makes the three easier to bury.
|
||||
if (hand.contains(THREE_OF_SPADES)) value -= 2
|
||||
return value
|
||||
}
|
||||
|
||||
private fun chooseBid(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||
val naming = legal.filterIsInstance<NameTrump>()
|
||||
if (naming.isNotEmpty()) {
|
||||
val hand = state.hands[seat]
|
||||
val best = naming
|
||||
.filter { it.suit != null }
|
||||
.maxByOrNull { suitValue(hand, it.suit!!) }
|
||||
return best ?: naming.first()
|
||||
}
|
||||
|
||||
val hand = state.hands[seat]
|
||||
val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
|
||||
val strength = suitValue(hand, bestSuit)
|
||||
|
||||
val bids = legal.filterIsInstance<MakeBid>().filter { !it.bid.noTrump }
|
||||
if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) {
|
||||
// Bid the floor rather than reaching; the contract is what matters.
|
||||
return bids.minBy { it.bid.points }
|
||||
}
|
||||
return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first()
|
||||
}
|
||||
|
||||
private fun choosePlay(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.size == 1) return PlayCard(cards.first())
|
||||
|
||||
if (state.trick.isEmpty()) {
|
||||
// Leading: pull with the top trump, else lead an ace.
|
||||
val trump = state.trump
|
||||
val topTrump = cards.filter { trump != null && it.suit == trump }
|
||||
.maxByOrNull { it.rank.order }
|
||||
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump)
|
||||
val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order }
|
||||
if (ace != null) return PlayCard(ace)
|
||||
return PlayCard(cards.minBy { it.rank.order })
|
||||
}
|
||||
|
||||
val ledSuit = state.trick.first().card.suit
|
||||
val best = state.trick.maxBy { trickStrength(it.card, state.trump, ledSuit) }
|
||||
val partnerWinning = partnerOf(best.seat) == seat
|
||||
val bestStrength = trickStrength(best.card, state.trump, ledSuit)
|
||||
val winners = cards.filter { trickStrength(it, state.trump, ledSuit) > bestStrength }
|
||||
|
||||
// The five of hearts is worth more than the trick it rides in on.
|
||||
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
|
||||
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
|
||||
|
||||
return when {
|
||||
// Partner has it and the trick is worth taking: let them have it,
|
||||
// unless the three of spades is in there.
|
||||
partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order })
|
||||
|
||||
fiveOnTable && winners.isNotEmpty() ->
|
||||
PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) })
|
||||
|
||||
// Nobody wants the three; duck if the trick is already lost.
|
||||
threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order })
|
||||
|
||||
winners.isNotEmpty() && !threeOnTable ->
|
||||
PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) })
|
||||
|
||||
else -> PlayCard(cards.minBy { it.rank.order })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.prolibertate.games.game.kaiser
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
|
||||
@Serializable
|
||||
data class KaiserOptions(
|
||||
/** Lowest opening bid. Five to eight, depending on the table. */
|
||||
val minimumBid: Int = 6,
|
||||
/** A no-trump bid outranks the same number bid with a trump suit. */
|
||||
val allowNoTrump: Boolean = true,
|
||||
val pointsToWin: Int = 52,
|
||||
/**
|
||||
* Going out the bottom loses. Without a floor a table that keeps bidding
|
||||
* and going down never reaches a result at all.
|
||||
*/
|
||||
val losingScore: Int = -52,
|
||||
) {
|
||||
init {
|
||||
require(minimumBid in 5..8) { "Kaiser opens between five and eight" }
|
||||
require(pointsToWin > 0) { "pointsToWin must be positive" }
|
||||
require(losingScore < 0) { "losingScore must be negative" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most a hand can be worth: eight tricks, plus five for the five of hearts,
|
||||
* less three for the three of spades. Bidding above it is bidding to fail, so
|
||||
* nothing above it is offered.
|
||||
*/
|
||||
const val MAX_BID = 10
|
||||
|
||||
/** The five of hearts, worth five to whoever takes the trick it falls in. */
|
||||
val FIVE_OF_HEARTS: Card = Card(Rank.FIVE, Suit.HEARTS)
|
||||
|
||||
/** The three of spades, which costs three. */
|
||||
val THREE_OF_SPADES: Card = Card(Rank.THREE, Suit.SPADES)
|
||||
|
||||
/**
|
||||
* Points in a trick: one for the trick itself, plus or minus whatever the two
|
||||
* counters are worth. Eight tricks and the two counters make ten points a hand.
|
||||
*/
|
||||
fun trickValue(cards: List<Card>): Int {
|
||||
var value = 1
|
||||
if (cards.contains(FIVE_OF_HEARTS)) value += 5
|
||||
if (cards.contains(THREE_OF_SPADES)) value -= 3
|
||||
return value
|
||||
}
|
||||
|
||||
fun partnerOf(seat: Int): Int = (seat + 2) % 4
|
||||
|
||||
fun teamOf(seat: Int): Int = seat % 2
|
||||
|
||||
@Serializable
|
||||
enum class KaiserPhase { BIDDING, PLAYING, HAND_OVER, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class PlayedCard(val seat: Int, val card: Card)
|
||||
|
||||
/** A bid of [points] tricks-worth, optionally in no trump. */
|
||||
@Serializable
|
||||
data class Bid(val points: Int, val noTrump: Boolean) {
|
||||
/** No trump beats the same number with a suit. */
|
||||
fun beats(other: Bid?): Boolean = when {
|
||||
other == null -> true
|
||||
points != other.points -> points > other.points
|
||||
else -> noTrump && !other.noTrump
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KaiserState(
|
||||
val options: KaiserOptions,
|
||||
val seed: Long,
|
||||
val handNumber: Int,
|
||||
val dealer: Int,
|
||||
val phase: KaiserPhase,
|
||||
val hands: List<List<Card>>,
|
||||
val handCounts: List<Int>,
|
||||
val turn: Int,
|
||||
val passes: Int,
|
||||
val highBid: Bid?,
|
||||
val highBidder: Int?,
|
||||
val trump: Suit?,
|
||||
val trick: List<PlayedCard>,
|
||||
/** Held on the table so a finished trick can be read before it is swept. */
|
||||
val completedTrick: List<PlayedCard> = emptyList(),
|
||||
val leader: Int,
|
||||
val lastTrickWinner: Int?,
|
||||
/** Points taken this hand, per team. */
|
||||
val handPoints: List<Int>,
|
||||
val scores: List<Int>,
|
||||
val log: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed interface KaiserMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("bid")
|
||||
data class MakeBid(val bid: Bid) : KaiserMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("pass")
|
||||
data object PassBid : KaiserMove
|
||||
|
||||
/** Only the winning bidder names trump, and only after the bidding. */
|
||||
@Serializable
|
||||
@SerialName("name_trump")
|
||||
data class NameTrump(val suit: Suit?) : KaiserMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("play")
|
||||
data class PlayCard(val card: Card) : KaiserMove
|
||||
|
||||
/**
|
||||
* Trick ranking. Trump beats everything else; otherwise only the led suit can
|
||||
* win. There are no bowers — Kaiser ranks straight down from the ace.
|
||||
*/
|
||||
fun trickStrength(card: Card, trump: Suit?, ledSuit: Suit): Int = when {
|
||||
trump != null && card.suit == trump -> 1000 + card.rank.order
|
||||
card.suit == ledSuit -> 100 + card.rank.order
|
||||
else -> 0
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package org.prolibertate.games.game.kaiser
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Kaiser as a pure state machine. See RULES-kaiser.md.
|
||||
*/
|
||||
object KaiserRules : GameRules<KaiserState, KaiserMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.KAISER
|
||||
|
||||
private const val CARDS_PER_HAND = 8
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): KaiserState {
|
||||
require(config.seats.size == 4) { "Kaiser is a four-handed game" }
|
||||
val options = json.decodeFromString<KaiserOptions>(config.optionsJson)
|
||||
return deal(options, config.seed, 0, 0, listOf(0, 0), listOf("Hand 1."))
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: KaiserOptions,
|
||||
seed: Long,
|
||||
handNumber: Int,
|
||||
dealer: Int,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): KaiserState {
|
||||
val random = Random(seed + handNumber * 7919L)
|
||||
val deck = Decks.kaiser().shuffledWith(random)
|
||||
val hands = (0 until 4).map { seat ->
|
||||
deck.subList(seat * CARDS_PER_HAND, (seat + 1) * CARDS_PER_HAND).toList()
|
||||
}
|
||||
return KaiserState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
handNumber = handNumber,
|
||||
dealer = dealer,
|
||||
phase = KaiserPhase.BIDDING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
turn = (dealer + 1) % 4,
|
||||
passes = 0,
|
||||
highBid = null,
|
||||
highBidder = null,
|
||||
trump = null,
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
leader = (dealer + 1) % 4,
|
||||
lastTrickWinner = null,
|
||||
handPoints = listOf(0, 0),
|
||||
scores = scores,
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
|
||||
/** Advances from [KaiserPhase.HAND_OVER] into the next deal. */
|
||||
fun nextHand(state: KaiserState): KaiserState {
|
||||
check(state.phase == KaiserPhase.HAND_OVER) { "Hand is not over" }
|
||||
val dealer = (state.dealer + 1) % 4
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
handNumber = state.handNumber + 1,
|
||||
dealer = dealer,
|
||||
scores = state.scores,
|
||||
log = state.log + "Hand ${state.handNumber + 2}.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: KaiserState): Int? = when (state.phase) {
|
||||
KaiserPhase.BIDDING, KaiserPhase.PLAYING -> state.turn
|
||||
KaiserPhase.HAND_OVER, KaiserPhase.GAME_OVER -> null
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legal moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun legalMoves(state: KaiserState, seat: Int): List<KaiserMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return when (state.phase) {
|
||||
KaiserPhase.BIDDING -> biddingMoves(state, seat)
|
||||
KaiserPhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
|
||||
KaiserPhase.HAND_OVER, KaiserPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun biddingMoves(state: KaiserState, seat: Int): List<KaiserMove> {
|
||||
// Trump is named as a separate move once the bidding is settled.
|
||||
if (state.highBidder == seat && state.trump == null && biddingClosed(state)) {
|
||||
return buildList {
|
||||
Suit.entries.forEach { add(NameTrump(it)) }
|
||||
if (state.highBid?.noTrump == true) {
|
||||
// A no-trump bid has already committed to none.
|
||||
clear()
|
||||
add(NameTrump(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val moves = mutableListOf<KaiserMove>()
|
||||
val floor = state.options.minimumBid
|
||||
val ceiling = MAX_BID
|
||||
for (points in floor..ceiling) {
|
||||
val plain = Bid(points, noTrump = false)
|
||||
if (plain.beats(state.highBid)) moves += MakeBid(plain)
|
||||
if (state.options.allowNoTrump) {
|
||||
val noTrump = Bid(points, noTrump = true)
|
||||
if (noTrump.beats(state.highBid)) moves += MakeBid(noTrump)
|
||||
}
|
||||
}
|
||||
|
||||
// The dealer is stuck with the minimum if the bidding would die out.
|
||||
val dealerMustBid = seat == state.dealer && state.highBid == null && state.passes == 3
|
||||
if (!dealerMustBid) moves += PassBid
|
||||
return moves
|
||||
}
|
||||
|
||||
private fun biddingClosed(state: KaiserState): Boolean =
|
||||
state.highBid != null && state.passes >= 3
|
||||
|
||||
/** Follow the led suit if you hold it. */
|
||||
fun playableCards(state: KaiserState, seat: Int): List<Card> {
|
||||
val hand = state.hands[seat]
|
||||
val led = state.trick.firstOrNull() ?: return hand
|
||||
val following = hand.filter { it.suit == led.card.suit }
|
||||
return following.ifEmpty { hand }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun applyMove(state: KaiserState, seat: Int, move: KaiserMove): KaiserState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is MakeBid -> applyBid(state, seat, move.bid)
|
||||
is PassBid -> applyPass(state, seat)
|
||||
is NameTrump -> applyNameTrump(state, move.suit)
|
||||
is PlayCard -> applyPlay(state, seat, move.card)
|
||||
}.normalised()
|
||||
}
|
||||
|
||||
private fun KaiserState.normalised(): KaiserState = copy(handCounts = hands.map { it.size })
|
||||
|
||||
private fun applyBid(state: KaiserState, seat: Int, bid: Bid): KaiserState {
|
||||
val next = state.copy(
|
||||
highBid = bid,
|
||||
highBidder = seat,
|
||||
// A fresh bid restarts the count of players willing to give way.
|
||||
passes = 0,
|
||||
turn = (seat + 1) % 4,
|
||||
log = state.log + "Seat $seat bids ${bid.points}${if (bid.noTrump) " no trump" else ""}.",
|
||||
)
|
||||
return if (biddingClosed(next)) next.copy(turn = seat) else next
|
||||
}
|
||||
|
||||
private fun applyPass(state: KaiserState, seat: Int): KaiserState {
|
||||
val passes = state.passes + 1
|
||||
val passed = state.copy(passes = passes, log = state.log + "Seat $seat passes.")
|
||||
|
||||
if (passes >= 4 && passed.highBid == null) {
|
||||
// Nobody wanted it: throw the hand in.
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
handNumber = state.handNumber + 1,
|
||||
dealer = (state.dealer + 1) % 4,
|
||||
scores = state.scores,
|
||||
log = passed.log + "Hand thrown in; redealing.",
|
||||
)
|
||||
}
|
||||
// Bidding is settled once everyone else has given way.
|
||||
if (passed.highBid != null && passes >= 3) {
|
||||
return passed.copy(turn = passed.highBidder!!)
|
||||
}
|
||||
return passed.copy(turn = (seat + 1) % 4)
|
||||
}
|
||||
|
||||
private fun applyNameTrump(state: KaiserState, suit: Suit?): KaiserState {
|
||||
val leader = state.highBidder!!
|
||||
return state.copy(
|
||||
phase = KaiserPhase.PLAYING,
|
||||
trump = suit,
|
||||
turn = leader,
|
||||
leader = leader,
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
log = state.log + if (suit == null) {
|
||||
"No trump."
|
||||
} else {
|
||||
"${suit.symbol} are trump."
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyPlay(state: KaiserState, seat: Int, card: Card): KaiserState {
|
||||
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, completedTrick = emptyList())
|
||||
|
||||
if (trick.size < 4) return played.copy(turn = (seat + 1) % 4)
|
||||
|
||||
val ledSuit = trick.first().card.suit
|
||||
val winner = trick.maxBy { trickStrength(it.card, state.trump, ledSuit) }.seat
|
||||
val points = trickValue(trick.map { it.card })
|
||||
|
||||
val handPoints = played.handPoints.toMutableList()
|
||||
handPoints[teamOf(winner)] = handPoints[teamOf(winner)] + points
|
||||
|
||||
val afterTrick = played.copy(
|
||||
trick = emptyList(),
|
||||
completedTrick = trick,
|
||||
handPoints = handPoints,
|
||||
leader = winner,
|
||||
turn = winner,
|
||||
lastTrickWinner = winner,
|
||||
log = state.log + "Seat $winner takes the trick ($points).",
|
||||
)
|
||||
|
||||
return if (afterTrick.hands.all { it.isEmpty() }) scoreHand(afterTrick) else afterTrick
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun scoreHand(state: KaiserState): KaiserState {
|
||||
val bidder = requireNotNull(state.highBidder)
|
||||
val bid = requireNotNull(state.highBid)
|
||||
val bidderTeam = teamOf(bidder)
|
||||
val defenders = 1 - bidderTeam
|
||||
val taken = state.handPoints[bidderTeam]
|
||||
|
||||
val scores = state.scores.toMutableList()
|
||||
val note: String
|
||||
if (taken >= bid.points) {
|
||||
// No-trump contracts pay double, which is what makes them worth bidding.
|
||||
val gained = if (bid.noTrump) taken * 2 else taken
|
||||
scores[bidderTeam] = scores[bidderTeam] + gained
|
||||
note = "Team $bidderTeam made ${bid.points} with $taken."
|
||||
} else {
|
||||
val lost = if (bid.noTrump) bid.points * 2 else bid.points
|
||||
scores[bidderTeam] = scores[bidderTeam] - lost
|
||||
note = "Team $bidderTeam went down ${bid.points}, taking only $taken."
|
||||
}
|
||||
// Defenders always keep what they took.
|
||||
scores[defenders] = scores[defenders] + state.handPoints[defenders]
|
||||
|
||||
// A game ends either by climbing to the target or by falling through
|
||||
// the floor, and the floor is what guarantees it ends at all.
|
||||
val reachedTarget = scores.indexOfFirst { it >= state.options.pointsToWin }
|
||||
val fellThrough = scores.indexOfFirst { it <= state.options.losingScore }
|
||||
val over = reachedTarget >= 0 || fellThrough >= 0
|
||||
val winner = when {
|
||||
reachedTarget >= 0 -> reachedTarget
|
||||
fellThrough >= 0 -> 1 - fellThrough
|
||||
else -> -1
|
||||
}
|
||||
|
||||
return state.copy(
|
||||
phase = if (over) KaiserPhase.GAME_OVER else KaiserPhase.HAND_OVER,
|
||||
scores = scores,
|
||||
log = state.log + note + if (over) listOf("Team $winner wins.") else emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: KaiserState): Boolean = state.phase == KaiserPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: KaiserState): String = when (state.phase) {
|
||||
KaiserPhase.GAME_OVER -> {
|
||||
val winner = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
|
||||
"Team $winner wins ${state.scores[0]}–${state.scores[1]}"
|
||||
}
|
||||
|
||||
else -> "We ${state.scores[0]} — They ${state.scores[1]}"
|
||||
}
|
||||
|
||||
override fun viewFor(state: KaiserState, seat: Int): KaiserState = state.copy(
|
||||
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||
handCounts = state.handCounts,
|
||||
)
|
||||
|
||||
override fun encodeState(state: KaiserState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): KaiserState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: KaiserMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): KaiserMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package org.prolibertate.games.game.wizard
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A heuristic Wizard opponent.
|
||||
*
|
||||
* Bidding counts the tricks the hand can reasonably expect: a wizard is one,
|
||||
* a jester is none, and everything else is worth a fraction depending on how
|
||||
* high it is and whether it is trump. In play it chases tricks while it is
|
||||
* short of its bid and ducks once it has them, which is the whole game.
|
||||
*/
|
||||
class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
GameAi<WizardState, WizardMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/**
|
||||
* Added to the expected-trick count before rounding. Optimists overbid
|
||||
* and go down; pessimists underbid and get caught taking tricks they
|
||||
* did not want.
|
||||
*/
|
||||
val bidBias: Double,
|
||||
) {
|
||||
EASY(bidBias = 0.35),
|
||||
NORMAL(bidBias = 0.0),
|
||||
CAUTIOUS(bidBias = -0.25),
|
||||
}
|
||||
|
||||
override fun chooseMove(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
WizardPhase.BIDDING -> chooseBid(state, seat, legal)
|
||||
WizardPhase.PLAYING -> choosePlay(state, seat, legal)
|
||||
else -> legal.first()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bidding
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseBid(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||
val naming = legal.filterIsInstance<ChooseTrump>()
|
||||
if (naming.isNotEmpty()) {
|
||||
val hand = state.hands[seat]
|
||||
val best = naming.maxByOrNull { suitStrength(hand, it.suit) }
|
||||
return best ?: naming.first()
|
||||
}
|
||||
|
||||
val bids = legal.filterIsInstance<MakeBid>()
|
||||
if (bids.isEmpty()) return legal.first()
|
||||
|
||||
val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias)
|
||||
.roundToInt()
|
||||
.coerceIn(0, state.cardsThisRound)
|
||||
// Screw-the-dealer may have taken the number we wanted away, so settle
|
||||
// for the nearest one still on offer.
|
||||
return bids.minBy { abs(it.tricks - wanted) }
|
||||
}
|
||||
|
||||
/** How much of a hand a given trump suit would be worth. */
|
||||
private fun suitStrength(hand: List<Card>, suit: Suit): Double =
|
||||
expectedTricks(hand, suit)
|
||||
|
||||
/**
|
||||
* Roughly how many tricks a hand should take. Deliberately blunt: it does
|
||||
* not know how many cards are out, only how good its own are.
|
||||
*/
|
||||
private fun expectedTricks(hand: List<Card>, trump: Suit?): Double {
|
||||
var expected = 0.0
|
||||
for (card in hand) {
|
||||
expected += when {
|
||||
isWizardCard(card) -> 1.0
|
||||
isJester(card) -> 0.0
|
||||
trump != null && card.suit == trump -> when {
|
||||
card.rank.order >= Rank.KING.order -> 0.9
|
||||
card.rank.order >= Rank.JACK.order -> 0.6
|
||||
else -> 0.3
|
||||
}
|
||||
|
||||
card.rank == Rank.ACE -> 0.75
|
||||
card.rank == Rank.KING -> 0.45
|
||||
card.rank == Rank.QUEEN -> 0.2
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Play
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlay(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.size == 1) return PlayCard(cards.first())
|
||||
|
||||
val bid = state.bids[seat] ?: 0
|
||||
val stillNeeded = bid - state.tricksWon[seat]
|
||||
val tricksLeft = state.hands[seat].size
|
||||
// Chase while we are short, and only while there are enough tricks left
|
||||
// to be worth chasing.
|
||||
val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft
|
||||
|
||||
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick))
|
||||
|
||||
val ledSuit = ledSuitOf(state.trick)
|
||||
val order = state.trick.size
|
||||
val bestOnTable = state.trick
|
||||
.withIndex()
|
||||
.maxOf { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
|
||||
|
||||
// A card played now takes the position after everything already down.
|
||||
val strengthOf = { card: Card -> trickStrength(card, order, state.trump, ledSuit) }
|
||||
val winners = cards.filter { strengthOf(it) > bestOnTable }
|
||||
val losers = cards.filter { strengthOf(it) <= bestOnTable }
|
||||
|
||||
return when {
|
||||
// Take it as cheaply as we can.
|
||||
wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) })
|
||||
// Cannot take it, or do not want it: throw the least useful card.
|
||||
losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) })
|
||||
// Everything we hold wins, and we did not want the trick. Take it
|
||||
// with the weakest winner so the good cards stay for later.
|
||||
else -> PlayCard(cards.minBy { strengthOf(it) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseLead(cards: List<Card>, trump: Suit?, wantsTrick: Boolean): Card {
|
||||
if (wantsTrick) {
|
||||
// A led wizard is an unloseable trick.
|
||||
cards.firstOrNull { isWizardCard(it) }?.let { return it }
|
||||
val topTrump = cards.filter { trump != null && it.suit == trump }
|
||||
.maxByOrNull { it.rank.order }
|
||||
if (topTrump != null && topTrump.rank.order >= Rank.QUEEN.order) return topTrump
|
||||
cards.firstOrNull { it.rank == Rank.ACE && !isJester(it) }?.let { return it }
|
||||
return cards.filterNot { isJester(it) }.maxByOrNull { it.rank.order } ?: cards.first()
|
||||
}
|
||||
|
||||
// Ducking: a led jester cannot take anything at all.
|
||||
cards.firstOrNull { isJester(it) }?.let { return it }
|
||||
return cards.filterNot { isWizardCard(it) }
|
||||
.minByOrNull { it.rank.order + if (trump != null && it.suit == trump) 20 else 0 }
|
||||
?: cards.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.prolibertate.games.game.wizard
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
|
||||
@Serializable
|
||||
data class WizardOptions(
|
||||
val playerCount: Int = 4,
|
||||
/**
|
||||
* Zero plays the full game: sixty cards divided by the table, so four
|
||||
* players get fifteen rounds. A smaller number shortens the evening.
|
||||
*/
|
||||
val rounds: Int = 0,
|
||||
/**
|
||||
* The dealer may not bid the number that would make the bids add up to the
|
||||
* tricks available, so somebody always misses. A common house rule.
|
||||
*/
|
||||
val screwTheDealer: Boolean = false,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..6) { "Wizard seats three to six" }
|
||||
require(rounds >= 0) { "rounds cannot be negative" }
|
||||
}
|
||||
|
||||
/** Sixty cards share out into this many rounds. */
|
||||
fun totalRounds(): Int {
|
||||
val full = 60 / playerCount
|
||||
return if (rounds in 1..full) rounds else full
|
||||
}
|
||||
}
|
||||
|
||||
fun isWizardCard(card: Card): Boolean = card.rank == Rank.WIZARD
|
||||
fun isJester(card: Card): Boolean = card.rank == Rank.JESTER
|
||||
|
||||
@Serializable
|
||||
enum class WizardPhase { BIDDING, PLAYING, ROUND_OVER, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class PlayedCard(val seat: Int, val card: Card)
|
||||
|
||||
@Serializable
|
||||
data class WizardState(
|
||||
val options: WizardOptions,
|
||||
val seed: Long,
|
||||
/** Zero-based; round n deals n+1 cards. */
|
||||
val round: Int,
|
||||
val dealer: Int,
|
||||
val phase: WizardPhase,
|
||||
val hands: List<List<Card>>,
|
||||
val handCounts: List<Int>,
|
||||
/** Turned up after the deal to set trump. Null in the last round. */
|
||||
val trumpCard: Card?,
|
||||
val trump: Suit?,
|
||||
/** Null until a seat has bid; one entry per seat. */
|
||||
val bids: List<Int?>,
|
||||
val tricksWon: List<Int>,
|
||||
val turn: Int,
|
||||
val trick: List<PlayedCard>,
|
||||
/** Held on the table so a finished trick can be read before it is swept. */
|
||||
val completedTrick: List<PlayedCard> = emptyList(),
|
||||
val leader: Int,
|
||||
val lastTrickWinner: Int?,
|
||||
val scores: List<Int>,
|
||||
val roundScores: List<Int>,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val cardsThisRound: Int get() = round + 1
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface WizardMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("bid")
|
||||
data class MakeBid(val tricks: Int) : WizardMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("play")
|
||||
data class PlayCard(val card: Card) : WizardMove
|
||||
|
||||
/** The dealer picks trump when a wizard is turned up. */
|
||||
@Serializable
|
||||
@SerialName("choose_trump")
|
||||
data class ChooseTrump(val suit: Suit) : WizardMove
|
||||
|
||||
/**
|
||||
* The suit a trick is being played to.
|
||||
*
|
||||
* A jester leads nothing — if the first card is a jester the suit is set by the
|
||||
* next ordinary card instead. A wizard reaching the front of that queue means
|
||||
* no suit is led at all and anything may be played.
|
||||
*/
|
||||
fun ledSuitOf(trick: List<PlayedCard>): Suit? {
|
||||
for (played in trick) {
|
||||
if (isWizardCard(played.card)) return null
|
||||
if (isJester(played.card)) continue
|
||||
return played.card.suit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks a card within a trick. The first wizard played takes it outright;
|
||||
* jesters never win unless nothing else was played.
|
||||
*/
|
||||
fun trickStrength(card: Card, order: Int, trump: Suit?, ledSuit: Suit?): Int = when {
|
||||
// Earlier wizards beat later ones, so subtract the position.
|
||||
isWizardCard(card) -> 10_000 - order
|
||||
// A jester only ever takes a trick of nothing but jesters, and then it is
|
||||
// the first one played that wins.
|
||||
isJester(card) -> 1
|
||||
trump != null && card.suit == trump -> 1_000 + card.rank.order
|
||||
ledSuit != null && card.suit == ledSuit -> 100 + card.rank.order
|
||||
// Off-suit and not trump: cannot win, but still beats a jester.
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Exact bids pay twenty plus ten a trick; missing costs ten a trick out. */
|
||||
fun scoreRound(bid: Int, won: Int): Int =
|
||||
if (bid == won) 20 + 10 * won else -10 * kotlin.math.abs(bid - won)
|
||||
@@ -0,0 +1,263 @@
|
||||
package org.prolibertate.games.game.wizard
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Wizard as a pure state machine. See RULES-wizard.md.
|
||||
*/
|
||||
object WizardRules : GameRules<WizardState, WizardMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.WIZARD
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): WizardState {
|
||||
val options = json.decodeFromString<WizardOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
return deal(options, config.seed, 0, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
|
||||
}
|
||||
|
||||
private fun deal(
|
||||
options: WizardOptions,
|
||||
seed: Long,
|
||||
round: Int,
|
||||
dealer: Int,
|
||||
scores: List<Int>,
|
||||
log: List<String>,
|
||||
): WizardState {
|
||||
val random = Random(seed + round * 7919L)
|
||||
var deck = Decks.wizard().shuffledWith(random)
|
||||
val cards = round + 1
|
||||
|
||||
val hands = mutableListOf<List<Card>>()
|
||||
repeat(options.playerCount) {
|
||||
hands += deck.take(cards)
|
||||
deck = deck.drop(cards)
|
||||
}
|
||||
|
||||
// Trump comes off the top of what is left. In the last round the whole
|
||||
// deck has been dealt, so there is nothing to turn and no trump.
|
||||
val trumpCard = deck.firstOrNull()
|
||||
val trump = when {
|
||||
trumpCard == null -> null
|
||||
isWizardCard(trumpCard) -> null // dealer chooses; see BIDDING below
|
||||
isJester(trumpCard) -> null
|
||||
else -> trumpCard.suit
|
||||
}
|
||||
|
||||
return WizardState(
|
||||
options = options,
|
||||
seed = seed,
|
||||
round = round,
|
||||
dealer = dealer,
|
||||
phase = WizardPhase.BIDDING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
trumpCard = trumpCard,
|
||||
trump = trump,
|
||||
bids = List(options.playerCount) { null },
|
||||
tricksWon = List(options.playerCount) { 0 },
|
||||
// A turned wizard means the dealer picks trump before anyone bids.
|
||||
turn = if (trumpCard != null && isWizardCard(trumpCard)) {
|
||||
dealer
|
||||
} else {
|
||||
(dealer + 1) % options.playerCount
|
||||
},
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
leader = (dealer + 1) % options.playerCount,
|
||||
lastTrickWinner = null,
|
||||
scores = scores,
|
||||
roundScores = List(options.playerCount) { 0 },
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
|
||||
/** Advances from [WizardPhase.ROUND_OVER] into the next deal. */
|
||||
fun nextRound(state: WizardState): WizardState {
|
||||
check(state.phase == WizardPhase.ROUND_OVER) { "Round is not over" }
|
||||
val dealer = (state.dealer + 1) % state.options.playerCount
|
||||
return deal(
|
||||
options = state.options,
|
||||
seed = state.seed,
|
||||
round = state.round + 1,
|
||||
dealer = dealer,
|
||||
scores = state.scores,
|
||||
log = state.log + "Round ${state.round + 2}.",
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: WizardState): Int? = when (state.phase) {
|
||||
WizardPhase.BIDDING, WizardPhase.PLAYING -> state.turn
|
||||
WizardPhase.ROUND_OVER, WizardPhase.GAME_OVER -> null
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legal moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun legalMoves(state: WizardState, seat: Int): List<WizardMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return when (state.phase) {
|
||||
WizardPhase.BIDDING -> biddingMoves(state, seat)
|
||||
WizardPhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) }
|
||||
WizardPhase.ROUND_OVER, WizardPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun biddingMoves(state: WizardState, seat: Int): List<WizardMove> {
|
||||
if (needsTrumpChoice(state)) return Suit.entries.map { ChooseTrump(it) }
|
||||
|
||||
val available = state.cardsThisRound
|
||||
val bids = (0..available).toMutableList()
|
||||
|
||||
// Screw the dealer: the last bid may not make the bids balance, so at
|
||||
// least one player is always going to be wrong.
|
||||
if (state.options.screwTheDealer && seat == state.dealer) {
|
||||
val alreadyBid = state.bids.filterNotNull().sum()
|
||||
val forbidden = available - alreadyBid
|
||||
bids.remove(forbidden)
|
||||
}
|
||||
return bids.map { MakeBid(it) }
|
||||
}
|
||||
|
||||
/** A wizard on top means the dealer names trump before bidding starts. */
|
||||
private fun needsTrumpChoice(state: WizardState): Boolean =
|
||||
state.trumpCard != null &&
|
||||
isWizardCard(state.trumpCard) &&
|
||||
state.trump == null &&
|
||||
state.bids.all { it == null }
|
||||
|
||||
/**
|
||||
* Follow the led suit if you hold it. Wizards and jesters are always legal
|
||||
* — playing one is how you get out of following.
|
||||
*/
|
||||
fun playableCards(state: WizardState, seat: Int): List<Card> {
|
||||
val hand = state.hands[seat]
|
||||
val led = ledSuitOf(state.trick) ?: return hand
|
||||
val following = hand.filter { it.suit == led && !isWizardCard(it) && !isJester(it) }
|
||||
if (following.isEmpty()) return hand
|
||||
// Keep the hand's own order so the UI does not reshuffle under the player.
|
||||
return hand.filter { it.suit == led || isWizardCard(it) || isJester(it) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun applyMove(state: WizardState, seat: Int, move: WizardMove): WizardState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is ChooseTrump -> state.copy(
|
||||
trump = move.suit,
|
||||
turn = (state.dealer + 1) % state.options.playerCount,
|
||||
log = state.log + "Dealer names ${move.suit.symbol} trump.",
|
||||
)
|
||||
|
||||
is MakeBid -> applyBid(state, seat, move.tricks)
|
||||
is PlayCard -> applyPlay(state, seat, move.card)
|
||||
}.normalised()
|
||||
}
|
||||
|
||||
private fun WizardState.normalised(): WizardState = copy(handCounts = hands.map { it.size })
|
||||
|
||||
private fun applyBid(state: WizardState, seat: Int, tricks: Int): WizardState {
|
||||
val bids = state.bids.toMutableList()
|
||||
bids[seat] = tricks
|
||||
val next = state.copy(
|
||||
bids = bids,
|
||||
log = state.log + "Seat $seat bids $tricks.",
|
||||
)
|
||||
|
||||
if (bids.all { it != null }) {
|
||||
val leader = (state.dealer + 1) % state.options.playerCount
|
||||
return next.copy(phase = WizardPhase.PLAYING, turn = leader, leader = leader)
|
||||
}
|
||||
return next.copy(turn = (seat + 1) % state.options.playerCount)
|
||||
}
|
||||
|
||||
private fun applyPlay(state: WizardState, seat: Int, card: Card): WizardState {
|
||||
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, completedTrick = emptyList())
|
||||
|
||||
if (trick.size < state.options.playerCount) {
|
||||
return played.copy(turn = (seat + 1) % state.options.playerCount)
|
||||
}
|
||||
|
||||
val ledSuit = ledSuitOf(trick)
|
||||
val winner = trick
|
||||
.withIndex()
|
||||
.maxBy { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
|
||||
.value.seat
|
||||
|
||||
val tricksWon = played.tricksWon.toMutableList()
|
||||
tricksWon[winner] = tricksWon[winner] + 1
|
||||
|
||||
val afterTrick = played.copy(
|
||||
trick = emptyList(),
|
||||
completedTrick = trick,
|
||||
tricksWon = tricksWon,
|
||||
leader = winner,
|
||||
turn = winner,
|
||||
lastTrickWinner = winner,
|
||||
log = state.log + "Seat $winner takes the trick.",
|
||||
)
|
||||
|
||||
return if (afterTrick.hands.all { it.isEmpty() }) scoreRoundEnd(afterTrick) else afterTrick
|
||||
}
|
||||
|
||||
private fun scoreRoundEnd(state: WizardState): WizardState {
|
||||
val roundScores = state.bids.mapIndexed { seat, bid ->
|
||||
scoreRound(bid ?: 0, state.tricksWon[seat])
|
||||
}
|
||||
val scores = state.scores.mapIndexed { seat, total -> total + roundScores[seat] }
|
||||
val lastRound = state.round + 1 >= state.options.totalRounds()
|
||||
|
||||
return state.copy(
|
||||
phase = if (lastRound) WizardPhase.GAME_OVER else WizardPhase.ROUND_OVER,
|
||||
roundScores = roundScores,
|
||||
scores = scores,
|
||||
log = state.log + roundScores.mapIndexed { seat, points ->
|
||||
"Seat $seat bid ${state.bids[seat]}, took ${state.tricksWon[seat]}: $points"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun isFinished(state: WizardState): Boolean = state.phase == WizardPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: WizardState): String = when (state.phase) {
|
||||
WizardPhase.GAME_OVER -> {
|
||||
val best = state.scores.indices.maxByOrNull { state.scores[it] } ?: 0
|
||||
"Seat $best wins with ${state.scores[best]}"
|
||||
}
|
||||
|
||||
else -> state.scores.mapIndexed { seat, s -> "S$seat:$s" }.joinToString(" ")
|
||||
}
|
||||
|
||||
override fun viewFor(state: WizardState, seat: Int): WizardState = state.copy(
|
||||
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||
handCounts = state.handCounts,
|
||||
)
|
||||
|
||||
override fun encodeState(state: WizardState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): WizardState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: WizardMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): WizardMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -30,12 +30,18 @@ import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.wizard.WizardOptions
|
||||
|
||||
private val setupJson = Json { encodeDefaults = true }
|
||||
private val setupJson = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose who is playing and which house rules apply.
|
||||
@@ -55,21 +61,21 @@ fun GameSetupScreen(
|
||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||
var president by remember { mutableStateOf(PresidentOptions()) }
|
||||
var golf by remember { mutableStateOf(GolfOptions()) }
|
||||
var kaiser by remember { mutableStateOf(KaiserOptions()) }
|
||||
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
|
||||
var wizard by remember { mutableStateOf(WizardOptions()) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence)
|
||||
GameCatalog.PRESIDENT -> setupJson.encodeToString(president)
|
||||
GameCatalog.GOLF -> setupJson.encodeToString(golf)
|
||||
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
|
||||
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
|
||||
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> 4
|
||||
GameCatalog.SEQUENCE -> sequence.playerCount
|
||||
GameCatalog.PRESIDENT -> president.playerCount
|
||||
GameCatalog.GOLF -> golf.playerCount
|
||||
else -> descriptor.minPlayers
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
|
||||
ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier ->
|
||||
Column(
|
||||
@@ -86,6 +92,12 @@ fun GameSetupScreen(
|
||||
GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it }
|
||||
GameCatalog.PRESIDENT -> PresidentOptionsEditor(president) { president = it }
|
||||
GameCatalog.GOLF -> GolfOptionsEditor(golf) { golf = it }
|
||||
GameCatalog.KAISER -> KaiserOptionsEditor(kaiser) { kaiser = it }
|
||||
GameCatalog.CRAZY_EIGHTS -> CrazyEightsOptionsEditor(crazyEights) {
|
||||
crazyEights = it
|
||||
}
|
||||
|
||||
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -131,9 +143,41 @@ private fun offlineConfig(
|
||||
seed = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
/**
|
||||
* How many seats a table needs, read back out of the options the host chose.
|
||||
*
|
||||
* The lobby only carries the options as JSON, so this is the one place that
|
||||
* knows a six-handed Wizard game needs six seats rather than the catalogue's
|
||||
* minimum — seat the wrong number and the rules engine rejects the table.
|
||||
*/
|
||||
fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
val fallback = GameCatalog.byId(gameId)?.minPlayers ?: 2
|
||||
return runCatching {
|
||||
when (gameId) {
|
||||
GameCatalog.EUCHRE, GameCatalog.KAISER -> 4
|
||||
GameCatalog.SEQUENCE ->
|
||||
setupJson.decodeFromString<SequenceOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.PRESIDENT ->
|
||||
setupJson.decodeFromString<PresidentOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.GOLF ->
|
||||
setupJson.decodeFromString<GolfOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CRAZY_EIGHTS ->
|
||||
setupJson.decodeFromString<CrazyEightsOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.WIZARD ->
|
||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
else -> fallback
|
||||
}
|
||||
}.getOrDefault(fallback)
|
||||
}
|
||||
|
||||
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
|
||||
// Partners sit opposite each other.
|
||||
GameCatalog.EUCHRE, GameCatalog.SEQUENCE -> seat % 2
|
||||
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2
|
||||
// Everyone else plays for themselves.
|
||||
else -> seat
|
||||
}
|
||||
@@ -305,6 +349,113 @@ private fun GolfOptionsEditor(options: GolfOptions, onChange: (GolfOptions) -> U
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KaiserOptionsEditor(options: KaiserOptions, onChange: (KaiserOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Lowest opening bid",
|
||||
values = listOf(5, 6, 7, 8),
|
||||
selected = options.minimumBid,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(minimumBid = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Game to",
|
||||
values = listOf(32, 52, 62),
|
||||
selected = options.pointsToWin,
|
||||
display = { "$it points" },
|
||||
onSelect = { target ->
|
||||
// The floor moves with the target: a long game needs a long
|
||||
// rope before a team that keeps going down is written off.
|
||||
onChange(options.copy(pointsToWin = target, losingScore = -target))
|
||||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = "No-trump bids",
|
||||
subtitle = "Pays double, and costs double when it goes down",
|
||||
checked = options.allowNoTrump,
|
||||
onChange = { onChange(options.copy(allowNoTrump = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "Four players in two partnerships. The 5♥ is worth five and the 3♠ " +
|
||||
"costs three, so a hand is worth ten points in all.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrazyEightsOptionsEditor(
|
||||
options: CrazyEightsOptions,
|
||||
onChange: (CrazyEightsOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(2, 3, 4, 5, 6),
|
||||
selected = options.playerCount,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Cards each",
|
||||
values = listOf(0, 5, 7, 9),
|
||||
selected = options.startingHand,
|
||||
display = { if (it == 0) "standard" else "$it" },
|
||||
onSelect = { onChange(options.copy(startingHand = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Rounds",
|
||||
values = listOf(1, 3, 5),
|
||||
selected = options.roundsToPlay,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(roundsToPlay = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Draw until you can play",
|
||||
subtitle = "Otherwise you draw a single card and the turn passes on",
|
||||
checked = options.drawUntilPlayable,
|
||||
onChange = { onChange(options.copy(drawUntilPlayable = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "Standard is seven cards heads-up and five otherwise. Eights are wild " +
|
||||
"and cost fifty if you are caught holding one.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(3, 4, 5, 6),
|
||||
selected = options.playerCount,
|
||||
display = { "$it" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Rounds",
|
||||
values = listOf(0, 5, 10),
|
||||
selected = options.rounds,
|
||||
display = { if (it == 0) "full game" else "$it" },
|
||||
onSelect = { onChange(options.copy(rounds = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Screw the dealer",
|
||||
subtitle = "The dealer may not make the bids add up, so somebody always misses",
|
||||
checked = options.screwTheDealer,
|
||||
onChange = { onChange(options.copy(screwTheDealer = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "A full game deals the whole sixty-card pack out: " +
|
||||
"${options.totalRounds()} rounds at this table.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.net.DiscoveredHost
|
||||
@@ -46,10 +45,9 @@ fun LobbyScreen(
|
||||
LaunchedEffect(route) {
|
||||
env.requestBluetoothPermissions()
|
||||
if (route.hosting) {
|
||||
val descriptor = GameCatalog.byId(route.gameId)
|
||||
val seatCount = descriptor?.let {
|
||||
if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers
|
||||
} ?: 4
|
||||
// Seat the table the host actually set up, not the catalogue
|
||||
// minimum: the rules engine rejects a table of the wrong size.
|
||||
val seatCount = seatCountFor(route.gameId, route.optionsJson)
|
||||
env.lobby.startHosting(
|
||||
gameId = route.gameId,
|
||||
optionsJson = route.optionsJson,
|
||||
|
||||
@@ -7,6 +7,11 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsState
|
||||
import org.prolibertate.games.game.euchre.EuchreAi
|
||||
import org.prolibertate.games.game.euchre.EuchreMove
|
||||
import org.prolibertate.games.game.euchre.EuchrePhase
|
||||
@@ -17,6 +22,11 @@ import org.prolibertate.games.game.golf.GolfMove
|
||||
import org.prolibertate.games.game.golf.GolfPhase
|
||||
import org.prolibertate.games.game.golf.GolfRules
|
||||
import org.prolibertate.games.game.golf.GolfState
|
||||
import org.prolibertate.games.game.kaiser.KaiserAi
|
||||
import org.prolibertate.games.game.kaiser.KaiserMove
|
||||
import org.prolibertate.games.game.kaiser.KaiserPhase
|
||||
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||
import org.prolibertate.games.game.kaiser.KaiserState
|
||||
import org.prolibertate.games.game.president.PresidentAi
|
||||
import org.prolibertate.games.game.president.PresidentMove
|
||||
import org.prolibertate.games.game.president.PresidentPhase
|
||||
@@ -26,13 +36,21 @@ import org.prolibertate.games.game.sequence.SequenceAi
|
||||
import org.prolibertate.games.game.sequence.SequenceMove
|
||||
import org.prolibertate.games.game.sequence.SequenceRules
|
||||
import org.prolibertate.games.game.sequence.SequenceState
|
||||
import org.prolibertate.games.game.wizard.WizardAi
|
||||
import org.prolibertate.games.game.wizard.WizardMove
|
||||
import org.prolibertate.games.game.wizard.WizardPhase
|
||||
import org.prolibertate.games.game.wizard.WizardRules
|
||||
import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.GolfScreen
|
||||
import org.prolibertate.games.ui.game.KaiserScreen
|
||||
import org.prolibertate.games.ui.game.PresidentScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
import org.prolibertate.games.ui.game.SequenceScreen
|
||||
import org.prolibertate.games.ui.game.WizardScreen
|
||||
|
||||
/**
|
||||
* Builds the match for the chosen game and hands it to that game's screen.
|
||||
@@ -154,6 +172,107 @@ fun PlayScreen(
|
||||
GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.KAISER -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<KaiserState, KaiserMove>(
|
||||
rules = KaiserRules,
|
||||
ai = KaiserAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
advanceIdle = { state ->
|
||||
if (state.phase == KaiserPhase.HAND_OVER) {
|
||||
KaiserRules.nextHand(state)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
holdBeforeNextMove = { state ->
|
||||
if (state.completedTrick.isNotEmpty()) {
|
||||
settings.scaled(TRICK_HOLD_MILLIS)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
KaiserScreen(
|
||||
controller = controller,
|
||||
localSeat = route.localSeat,
|
||||
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.WIZARD -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<WizardState, WizardMove>(
|
||||
rules = WizardRules,
|
||||
ai = WizardAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
advanceIdle = { state ->
|
||||
if (state.phase == WizardPhase.ROUND_OVER) {
|
||||
WizardRules.nextRound(state)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
// Bids against tricks taken are read before the next deal.
|
||||
awaitsConfirmation = { state -> state.phase == WizardPhase.ROUND_OVER },
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
holdBeforeNextMove = { state ->
|
||||
if (state.completedTrick.isNotEmpty()) {
|
||||
settings.scaled(TRICK_HOLD_MILLIS)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
WizardScreen(
|
||||
controller = controller,
|
||||
localSeat = route.localSeat,
|
||||
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
GameCatalog.CRAZY_EIGHTS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<CrazyEightsState, CrazyEightsMove>(
|
||||
rules = CrazyEightsRules,
|
||||
ai = CrazyEightsAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
advanceIdle = { state ->
|
||||
if (state.phase == CrazyEightsPhase.ROUND_OVER) {
|
||||
CrazyEightsRules.nextRound(state)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
awaitsConfirmation = { state ->
|
||||
state.phase == CrazyEightsPhase.ROUND_OVER
|
||||
},
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
else -> Text("That game isn't playable yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsState
|
||||
import org.prolibertate.games.game.crazyeights.DrawCard
|
||||
import org.prolibertate.games.game.crazyeights.PassTurn
|
||||
import org.prolibertate.games.game.crazyeights.PlayCard
|
||||
import org.prolibertate.games.game.crazyeights.crazyEightsPenalty
|
||||
import org.prolibertate.games.game.crazyeights.isWild
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.CardBackView
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
|
||||
/**
|
||||
* The Crazy 8s table.
|
||||
*
|
||||
* The one thing this screen has to get right is that the suit being followed is
|
||||
* not always the suit on top of the pile — an eight changes it — so the suit in
|
||||
* force is stated in words rather than left to be read off the card.
|
||||
*/
|
||||
@Composable
|
||||
fun CrazyEightsScreen(
|
||||
controller: MatchController<CrazyEightsState, CrazyEightsMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
|
||||
// Playing an eight is two decisions: the card, then the suit it turns the
|
||||
// game to. The card is held here while the suit is chosen.
|
||||
var pendingWild by remember { mutableStateOf<Card?>(null) }
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ScoreLine(current, localSeat)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(FeltGreenDark),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PileArea(current, cardWidth)
|
||||
}
|
||||
|
||||
OpponentCounts(current, localSeat)
|
||||
|
||||
if (current.phase == CrazyEightsPhase.PLAYING) {
|
||||
TurnControls(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
legal = legal,
|
||||
pendingWild = pendingWild,
|
||||
onCancelWild = { pendingWild = null },
|
||||
onMove = { pendingWild = null; controller.submit(it) },
|
||||
)
|
||||
}
|
||||
|
||||
HandRow(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
legal = legal,
|
||||
cardWidth = cardWidth,
|
||||
pendingWild = pendingWild,
|
||||
onTap = { card ->
|
||||
if (isWild(card)) {
|
||||
pendingWild = if (pendingWild == card) null else card
|
||||
} else {
|
||||
pendingWild = null
|
||||
controller.submit(PlayCard(card))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (current.phase != CrazyEightsPhase.PLAYING) {
|
||||
RoundScoreDialog(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
onContinue = { controller.confirmAdvance() },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreLine(state: CrazyEightsState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Your penalty so far: ${state.scores[localSeat]}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("Play ${state.suitInForce.symbol}", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "${state.stock.size} left in the stock",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PileArea(state: CrazyEightsState, cardWidth: Dp) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Stock",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
if (state.stock.isEmpty()) {
|
||||
Text(
|
||||
text = "empty",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
CardBackView(width = cardWidth)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Pile",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
state.topCard?.let { PlayingCardView(card = it, width = cardWidth) }
|
||||
}
|
||||
}
|
||||
// An eight on top says nothing about what may be played next, so the
|
||||
// suit in force is spelled out under the pile.
|
||||
Text(
|
||||
text = "Following ${state.suitInForce.symbol} ${state.suitInForce.name.lowercase()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OpponentCounts(state: CrazyEightsState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
(0 until state.options.playerCount)
|
||||
.filter { it != localSeat }
|
||||
.forEach { seat ->
|
||||
val count = state.handCounts.getOrElse(seat) { 0 }
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "Seat $seat",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
repeat(count.coerceAtMost(8)) { CardBackView(width = 12.dp) }
|
||||
}
|
||||
Text("$count", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TurnControls(
|
||||
state: CrazyEightsState,
|
||||
localSeat: Int,
|
||||
legal: List<CrazyEightsMove>,
|
||||
pendingWild: Card?,
|
||||
onCancelWild: () -> Unit,
|
||||
onMove: (CrazyEightsMove) -> Unit,
|
||||
) {
|
||||
if (legal.isEmpty()) {
|
||||
Text("Seat ${state.turn} to play…", style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingWild != null) {
|
||||
val suits = legal.filterIsInstance<PlayCard>()
|
||||
.filter { it.card == pendingWild }
|
||||
.mapNotNull { move -> move.nominatedSuit?.let { it to move } }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Playing ${pendingWild.label} — name the suit", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
suits.forEach { (suit, move) ->
|
||||
Button(onClick = { onMove(move) }) {
|
||||
Text("${suit.symbol} ${suit.name.lowercase()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onCancelWild, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Pick a different card")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val canDraw = legal.contains(DrawCard)
|
||||
val canPass = legal.contains(PassTurn)
|
||||
val playable = legal.filterIsInstance<PlayCard>().isNotEmpty()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = when {
|
||||
playable -> "Your turn — tap a card"
|
||||
canDraw -> "Nothing to play"
|
||||
else -> "Nothing to play and nothing to draw"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (canDraw) {
|
||||
Button(onClick = { onMove(DrawCard) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
if (state.options.drawUntilPlayable) {
|
||||
"Draw until something fits"
|
||||
} else {
|
||||
"Draw a card"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (canPass) {
|
||||
OutlinedButton(onClick = { onMove(PassTurn) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Pass")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRow(
|
||||
state: CrazyEightsState,
|
||||
localSeat: Int,
|
||||
legal: List<CrazyEightsMove>,
|
||||
cardWidth: Dp,
|
||||
pendingWild: Card?,
|
||||
onTap: (Card) -> Unit,
|
||||
) {
|
||||
val hand = state.hands.getOrNull(localSeat).orEmpty()
|
||||
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = "Your hand — ${hand.sumOf { crazyEightsPenalty(it) }} in penalties",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (hand.isEmpty()) Text("—", style = MaterialTheme.typography.bodySmall)
|
||||
hand.forEach { card ->
|
||||
val canPlay = card in playable
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = cardWidth,
|
||||
enabled = canPlay,
|
||||
selected = card == pendingWild,
|
||||
caption = if (isWild(card)) "WILD" else null,
|
||||
onClick = { onTap(card) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The card at the end of a round: what everyone was caught holding. */
|
||||
@Composable
|
||||
private fun RoundScoreDialog(
|
||||
state: CrazyEightsState,
|
||||
localSeat: Int,
|
||||
onContinue: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
var dismissed by remember(state.roundNumber, state.phase) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
val finalRound = state.phase == CrazyEightsPhase.GAME_OVER
|
||||
val standings = (0 until state.options.playerCount).sortedBy { state.scores[it] }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = {
|
||||
Text(
|
||||
if (finalRound) {
|
||||
"Final score"
|
||||
} else {
|
||||
"Round ${state.roundNumber + 1} of ${state.options.roundsToPlay}"
|
||||
}
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = state.roundWinner?.let {
|
||||
if (it == localSeat) "You went out." else "Seat $it went out."
|
||||
} ?: "The round was blocked — nobody went out.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
standings.forEach { seat ->
|
||||
val you = seat == localSeat
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = if (you) "You" else "Seat $seat",
|
||||
modifier = Modifier.weight(1f),
|
||||
fontWeight = if (you) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Text("${state.scores[seat]}")
|
||||
}
|
||||
}
|
||||
Text("Lowest penalty wins.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
dismissed = true
|
||||
if (finalRound) onExit() else onContinue()
|
||||
}) {
|
||||
Text(if (finalRound) "Back to the menu" else "Deal the next round")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
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.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.kaiser.FIVE_OF_HEARTS
|
||||
import org.prolibertate.games.game.kaiser.KaiserMove
|
||||
import org.prolibertate.games.game.kaiser.KaiserPhase
|
||||
import org.prolibertate.games.game.kaiser.KaiserState
|
||||
import org.prolibertate.games.game.kaiser.MakeBid
|
||||
import org.prolibertate.games.game.kaiser.NameTrump
|
||||
import org.prolibertate.games.game.kaiser.PassBid
|
||||
import org.prolibertate.games.game.kaiser.PlayCard
|
||||
import org.prolibertate.games.game.kaiser.THREE_OF_SPADES
|
||||
import org.prolibertate.games.game.kaiser.teamOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.PrimaryAction
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
|
||||
/**
|
||||
* The Kaiser table.
|
||||
*
|
||||
* Laid out like the Euchre table — partners opposite, each card on the side of
|
||||
* whoever played it — with the two counters called out wherever they appear,
|
||||
* since the whole hand is really played for those two cards.
|
||||
*/
|
||||
@Composable
|
||||
fun KaiserScreen(
|
||||
controller: MatchController<KaiserState, KaiserMove>,
|
||||
localSeat: Int,
|
||||
trickHoldMillis: Long,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val compact = maxWidth < 600.dp
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
|
||||
.coerceIn(44.dp, 96.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ScoreBar(current, localSeat)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(FeltGreenDark),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
|
||||
}
|
||||
|
||||
notice?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
when (current.phase) {
|
||||
KaiserPhase.BIDDING ->
|
||||
BiddingControls(current, localSeat, legal) { controller.submit(it) }
|
||||
|
||||
KaiserPhase.HAND_OVER -> Text(
|
||||
text = current.log.lastOrNull().orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
|
||||
KaiserPhase.GAME_OVER -> {
|
||||
Text("Game over.", fontWeight = FontWeight.Bold)
|
||||
PrimaryAction(text = "Back to the menu") { onExit() }
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
HandRow(current, localSeat, legal, cardWidth) { controller.submit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreBar(state: KaiserState, localSeat: Int) {
|
||||
val myTeam = teamOf(localSeat)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Us ${state.scores[myTeam]} — Them ${state.scores[1 - myTeam]}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "This hand: ${state.handPoints[myTeam]} to us, " +
|
||||
"${state.handPoints[1 - myTeam]} to them",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
val bid = state.highBid
|
||||
Text(
|
||||
text = when {
|
||||
bid == null -> "Bidding"
|
||||
state.trump == null && state.phase != KaiserPhase.BIDDING -> "No trump"
|
||||
state.trump != null -> "Trump ${state.trump!!.symbol}"
|
||||
else -> "Bid ${bid.points}"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (bid != null) {
|
||||
val bidder = state.highBidder
|
||||
Text(
|
||||
text = "Contract ${bid.points}${if (bid.noTrump) " no trump" else ""}" +
|
||||
(bidder?.let { " by ${seatLabel(relativePosition(it, localSeat))}" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a seat sits relative to you. Partners sit opposite, so 2 is yours. */
|
||||
private fun relativePosition(seat: Int, localSeat: Int): Int = ((seat - localSeat) % 4 + 4) % 4
|
||||
|
||||
private fun seatLabel(relative: Int): String = when (relative) {
|
||||
0 -> "You"
|
||||
1 -> "Left"
|
||||
2 -> "Partner"
|
||||
else -> "Right"
|
||||
}
|
||||
|
||||
/** Duration of the sweep towards the winner. */
|
||||
private const val SWEEP_MILLIS = 450
|
||||
|
||||
@Composable
|
||||
private fun TrickArea(
|
||||
state: KaiserState,
|
||||
localSeat: Int,
|
||||
cardWidth: Dp,
|
||||
trickHoldMillis: Long,
|
||||
) {
|
||||
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 = SWEEP_MILLIS),
|
||||
label = "kaiserSweep",
|
||||
)
|
||||
|
||||
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)) {
|
||||
if (cardsOnTable.isEmpty()) {
|
||||
Text(
|
||||
text = when {
|
||||
state.phase == KaiserPhase.BIDDING -> "Bidding"
|
||||
state.turn == localSeat -> "Lead a card"
|
||||
else -> "Waiting…"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
cardsOnTable.forEach { played ->
|
||||
val relative = relativePosition(played.seat, localSeat)
|
||||
val alignment = when (relative) {
|
||||
0 -> Alignment.BottomCenter
|
||||
1 -> Alignment.CenterStart
|
||||
2 -> Alignment.TopCenter
|
||||
else -> Alignment.CenterEnd
|
||||
}
|
||||
val rotation = when (relative) {
|
||||
1 -> 90f
|
||||
2 -> 180f
|
||||
3 -> -90f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(alignment)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (dirX * travelPx * sweep).roundToInt(),
|
||||
y = (dirY * travelPx * sweep).roundToInt(),
|
||||
)
|
||||
}
|
||||
.alpha(1f - sweep),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
caption = counterCaption(played.card),
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(relative),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.phase == KaiserPhase.PLAYING && state.trick.size < 4) {
|
||||
val waitingOn = relativePosition(state.turn, localSeat)
|
||||
if (waitingOn != 0 && state.trick.none { it.seat == state.turn }) {
|
||||
Text(
|
||||
text = "${seatLabel(waitingOn)} to play",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The two cards that decide the hand, named on their faces. */
|
||||
private fun counterCaption(card: Card): String? = when (card) {
|
||||
FIVE_OF_HEARTS -> "+5"
|
||||
THREE_OF_SPADES -> "−3"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BiddingControls(
|
||||
state: KaiserState,
|
||||
localSeat: Int,
|
||||
legal: List<KaiserMove>,
|
||||
onMove: (KaiserMove) -> Unit,
|
||||
) {
|
||||
if (legal.isEmpty()) {
|
||||
val waitingOn = relativePosition(state.turn, localSeat)
|
||||
Text("Waiting for ${seatLabel(waitingOn).lowercase()} to bid…")
|
||||
return
|
||||
}
|
||||
|
||||
val naming = legal.filterIsInstance<NameTrump>()
|
||||
if (naming.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("You won the bidding — name trump", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
naming.forEach { move ->
|
||||
Button(onClick = { onMove(move) }) {
|
||||
Text(move.suit?.let { "${it.symbol} ${it.name.lowercase()}" } ?: "No trump")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val bids = legal.filterIsInstance<MakeBid>()
|
||||
val canPass = legal.contains(PassBid)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = state.highBid?.let { high ->
|
||||
"Bidding stands at ${high.points}${if (high.noTrump) " no trump" else ""}"
|
||||
} ?: "Open the bidding",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (!canPass) {
|
||||
Text(
|
||||
text = "Everyone passed, so as dealer you have to take it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Wraps rather than scrolls, so no bid can be pushed off the edge.
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
bids.forEach { move ->
|
||||
Button(onClick = { onMove(move) }) {
|
||||
Text("${move.bid.points}${if (move.bid.noTrump) " NT" else ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "A no-trump contract pays double, and costs double if it goes down.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (canPass) {
|
||||
OutlinedButton(onClick = { onMove(PassBid) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Pass")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRow(
|
||||
state: KaiserState,
|
||||
seat: Int,
|
||||
legal: List<KaiserMove>,
|
||||
cardWidth: Dp,
|
||||
onPlay: (KaiserMove) -> Unit,
|
||||
) {
|
||||
val hand = state.hands.getOrNull(seat).orEmpty()
|
||||
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = if (playable.isNotEmpty()) "Your turn" else "Your hand",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (hand.isEmpty()) {
|
||||
Box(modifier = Modifier.size(cardWidth)) {
|
||||
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
hand.forEach { card ->
|
||||
val canPlay = card in playable
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = cardWidth,
|
||||
enabled = canPlay,
|
||||
caption = counterCaption(card),
|
||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
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.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlinx.coroutines.delay
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.wizard.ChooseTrump
|
||||
import org.prolibertate.games.game.wizard.MakeBid
|
||||
import org.prolibertate.games.game.wizard.PlayCard
|
||||
import org.prolibertate.games.game.wizard.WizardMove
|
||||
import org.prolibertate.games.game.wizard.WizardPhase
|
||||
import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.game.wizard.isJester
|
||||
import org.prolibertate.games.game.wizard.isWizardCard
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
|
||||
/**
|
||||
* The Wizard table.
|
||||
*
|
||||
* Three to six can play, so seats are placed round a circle rather than at four
|
||||
* fixed edges: you are always at the bottom and everyone else is spaced out from
|
||||
* there, which keeps the same layout code working at every table size.
|
||||
*/
|
||||
@Composable
|
||||
fun WizardScreen(
|
||||
controller: MatchController<WizardState, WizardMove>,
|
||||
localSeat: Int,
|
||||
trickHoldMillis: Long,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RoundBar(current, localSeat)
|
||||
BidTally(current, localSeat)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(FeltGreenDark),
|
||||
) {
|
||||
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
|
||||
}
|
||||
|
||||
notice?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
if (current.phase == WizardPhase.BIDDING) {
|
||||
BiddingControls(current, localSeat, legal) { controller.submit(it) }
|
||||
}
|
||||
|
||||
HandRow(current, localSeat, legal, cardWidth) { controller.submit(it) }
|
||||
}
|
||||
|
||||
if (current.phase == WizardPhase.ROUND_OVER || current.phase == WizardPhase.GAME_OVER) {
|
||||
RoundScoreDialog(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
onContinue = { controller.confirmAdvance() },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoundBar(state: WizardState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Round ${state.round + 1} of ${state.options.totalRounds()}" +
|
||||
" — ${state.cardsThisRound} card(s)",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Your score: ${state.scores[localSeat]}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = state.trump?.let { "Trump ${it.symbol}" } ?: "No trump",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
state.trumpCard?.let {
|
||||
Text("Turned ${it.label}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Everyone's bid against what they have actually taken — the whole game in a row. */
|
||||
@Composable
|
||||
private fun BidTally(state: WizardState, localSeat: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
(0 until state.options.playerCount).forEach { seat ->
|
||||
val bid = state.bids[seat]
|
||||
val won = state.tricksWon[seat]
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = if (seat == localSeat) "You" else seatLabel(seat, localSeat),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (seat == state.turn) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
Text(
|
||||
text = bid?.let { "$won / $it" } ?: "—",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
// Green while the bid is still on, red once it cannot be made.
|
||||
color = when {
|
||||
bid == null -> MaterialTheme.colorScheme.onSurface
|
||||
won == bid -> MaterialTheme.colorScheme.primary
|
||||
won > bid -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seats are named by how far round the table they sit from you. */
|
||||
private fun seatLabel(seat: Int, localSeat: Int): String =
|
||||
if (seat == localSeat) "You" else "Seat $seat"
|
||||
|
||||
/** Duration of the sweep towards the winner. */
|
||||
private const val SWEEP_MILLIS = 450
|
||||
|
||||
@Composable
|
||||
private fun TrickArea(
|
||||
state: WizardState,
|
||||
localSeat: Int,
|
||||
cardWidth: Dp,
|
||||
trickHoldMillis: Long,
|
||||
) {
|
||||
val players = state.options.playerCount
|
||||
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 = SWEEP_MILLIS),
|
||||
label = "wizardSweep",
|
||||
)
|
||||
|
||||
val winnerRelative = state.lastTrickWinner
|
||||
?.let { ((it - localSeat) % players + players) % players }
|
||||
?: 0
|
||||
val winnerAngle = 2.0 * PI * winnerRelative / players
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||
// Cards sit on a circle that fits inside whatever space the table has.
|
||||
val radius = (minOf(maxWidth, maxHeight) / 2 - cardWidth * 0.7f).coerceAtLeast(0.dp)
|
||||
val radiusPx = with(LocalDensity.current) { radius.toPx() }
|
||||
val travelPx = with(LocalDensity.current) { (cardWidth * 2f).toPx() }
|
||||
|
||||
if (cardsOnTable.isEmpty()) {
|
||||
Text(
|
||||
text = when {
|
||||
state.phase == WizardPhase.BIDDING -> "Bidding"
|
||||
state.turn == localSeat -> "Lead a card"
|
||||
else -> "Waiting…"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
cardsOnTable.forEach { played ->
|
||||
val relative = ((played.seat - localSeat) % players + players) % players
|
||||
val angle = 2.0 * PI * relative / players
|
||||
// Relative seat 0 is you, at the bottom; the rest run clockwise.
|
||||
val x = -sin(angle) * radiusPx
|
||||
val y = cos(angle) * radiusPx
|
||||
// Turn each card to face whoever played it.
|
||||
val rotation = (relative * 360f / players)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (x + -sin(winnerAngle) * travelPx * sweep).roundToInt(),
|
||||
y = (y + cos(winnerAngle) * travelPx * sweep).roundToInt(),
|
||||
)
|
||||
}
|
||||
.alpha(1f - sweep),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PlayingCardView(
|
||||
card = played.card,
|
||||
width = cardWidth,
|
||||
caption = specialCaption(played.card),
|
||||
modifier = Modifier.rotate(rotation),
|
||||
)
|
||||
Text(
|
||||
text = seatLabel(played.seat, localSeat),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (sweeping && state.lastTrickWinner != null) {
|
||||
Text(
|
||||
text = "${seatLabel(state.lastTrickWinner!!, localSeat)} takes it",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizards and jesters carry no rank or suit anyone can read off the face, so
|
||||
* they say what they do instead.
|
||||
*/
|
||||
private fun specialCaption(card: Card): String? = when {
|
||||
isWizardCard(card) -> "WIZARD"
|
||||
isJester(card) -> "JESTER"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun BiddingControls(
|
||||
state: WizardState,
|
||||
localSeat: Int,
|
||||
legal: List<WizardMove>,
|
||||
onMove: (WizardMove) -> Unit,
|
||||
) {
|
||||
if (legal.isEmpty()) {
|
||||
Text(
|
||||
text = "Waiting for ${seatLabel(state.turn, localSeat).lowercase()} to bid…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val trumpChoices = legal.filterIsInstance<ChooseTrump>()
|
||||
if (trumpChoices.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("A wizard was turned — you name trump", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
trumpChoices.forEach { move ->
|
||||
Button(onClick = { onMove(move) }) {
|
||||
Text("${move.suit.symbol} ${move.suit.name.lowercase()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val bids = legal.filterIsInstance<MakeBid>()
|
||||
val bidSoFar = state.bids.filterNotNull().sum()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "How many of the ${state.cardsThisRound} tricks will you take?",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Bid so far: $bidSoFar. Hit your bid exactly for 20 plus 10 a trick; " +
|
||||
"miss it and it costs 10 for every trick either way.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (state.options.screwTheDealer && state.turn == state.dealer) {
|
||||
Text(
|
||||
text = "Screw the dealer: you may not make the bids add up.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
bids.forEach { move ->
|
||||
Button(onClick = { onMove(move) }) { Text("${move.tricks}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRow(
|
||||
state: WizardState,
|
||||
seat: Int,
|
||||
legal: List<WizardMove>,
|
||||
cardWidth: Dp,
|
||||
onPlay: (WizardMove) -> Unit,
|
||||
) {
|
||||
val hand = state.hands.getOrNull(seat).orEmpty()
|
||||
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = if (playable.isNotEmpty()) "Your turn" else "Your hand",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (hand.isEmpty()) {
|
||||
Box(modifier = Modifier.size(cardWidth)) {
|
||||
Text("—", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
hand.forEach { card ->
|
||||
val canPlay = card in playable
|
||||
PlayingCardView(
|
||||
card = card,
|
||||
width = cardWidth,
|
||||
enabled = canPlay,
|
||||
caption = specialCaption(card),
|
||||
onClick = { if (canPlay) onPlay(PlayCard(card)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bids against tricks taken, and what that was worth, before the next deal. */
|
||||
@Composable
|
||||
private fun RoundScoreDialog(
|
||||
state: WizardState,
|
||||
localSeat: Int,
|
||||
onContinue: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
var dismissed by remember(state.round, state.phase) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
val finalRound = state.phase == WizardPhase.GAME_OVER
|
||||
val standings = (0 until state.options.playerCount).sortedByDescending { state.scores[it] }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = {
|
||||
Text(
|
||||
if (finalRound) {
|
||||
"Final score"
|
||||
} else {
|
||||
"Round ${state.round + 1} of ${state.options.totalRounds()}"
|
||||
}
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Player", modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold)
|
||||
Text("Bid", fontWeight = FontWeight.Bold)
|
||||
Text(" Took", fontWeight = FontWeight.Bold)
|
||||
Text(" Round", fontWeight = FontWeight.Bold)
|
||||
Text(" Total", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
standings.forEach { seat ->
|
||||
val you = seat == localSeat
|
||||
val weight = if (you) FontWeight.Bold else FontWeight.Normal
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = if (you) "You" else "Seat $seat",
|
||||
modifier = Modifier.weight(1f),
|
||||
fontWeight = weight,
|
||||
)
|
||||
Text("${state.bids[seat] ?: 0}")
|
||||
Text(" ${state.tricksWon[seat]}")
|
||||
Text(" ${state.roundScores[seat]}")
|
||||
Text(" ${state.scores[seat]}", fontWeight = weight)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (finalRound) {
|
||||
"Highest score wins — seat ${standings.first()} takes it."
|
||||
} else {
|
||||
"Highest score wins."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
dismissed = true
|
||||
if (finalRound) onExit() else onContinue()
|
||||
}) {
|
||||
Text(if (finalRound) "Back to the menu" else "Deal the next round")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,9 @@ class CatalogTest {
|
||||
GameCatalog.SEQUENCE,
|
||||
GameCatalog.PRESIDENT,
|
||||
GameCatalog.GOLF,
|
||||
GameCatalog.KAISER,
|
||||
GameCatalog.CRAZY_EIGHTS,
|
||||
GameCatalog.WIZARD,
|
||||
),
|
||||
GameCatalog.playable.map { it.id }.toSet(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package org.prolibertate.games.game.crazyeights
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
class CrazyEightsRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: CrazyEightsOptions = CrazyEightsOptions(),
|
||||
seed: Long = 21L,
|
||||
) = TableConfig(
|
||||
gameId = "crazy8s",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||
|
||||
private fun playingState(
|
||||
hands: List<List<Card>>,
|
||||
top: Card,
|
||||
suitInForce: Suit = top.suit,
|
||||
stock: List<Card> = emptyList(),
|
||||
turn: Int = 0,
|
||||
options: CrazyEightsOptions = CrazyEightsOptions(playerCount = hands.size),
|
||||
drawnThisTurn: Int = 0,
|
||||
) = CrazyEightsState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
roundNumber = 0,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
stock = stock,
|
||||
discard = listOf(top),
|
||||
suitInForce = suitInForce,
|
||||
turn = turn,
|
||||
drawnThisTurn = drawnThisTurn,
|
||||
scores = List(hands.size) { 0 },
|
||||
roundWinner = null,
|
||||
phase = CrazyEightsPhase.PLAYING,
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
// -- Matching -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a card matches on suit or on rank`() {
|
||||
val top = c(Rank.SEVEN, Suit.HEARTS)
|
||||
assertTrue("same suit", canPlay(c(Rank.KING, Suit.HEARTS), top, Suit.HEARTS))
|
||||
assertTrue("same rank", canPlay(c(Rank.SEVEN, Suit.CLUBS), top, Suit.HEARTS))
|
||||
assertFalse("neither", canPlay(c(Rank.KING, Suit.CLUBS), top, Suit.HEARTS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an eight always plays`() {
|
||||
val top = c(Rank.SEVEN, Suit.HEARTS)
|
||||
Suit.entries.forEach { suit ->
|
||||
assertTrue(canPlay(c(Rank.EIGHT, suit), top, Suit.HEARTS))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the suit in force overrides the top card`() {
|
||||
// An eight was played on a heart and clubs were called.
|
||||
val top = c(Rank.EIGHT, Suit.HEARTS)
|
||||
assertTrue(canPlay(c(Rank.TWO, Suit.CLUBS), top, Suit.CLUBS))
|
||||
assertFalse("hearts no longer run", canPlay(c(Rank.TWO, Suit.HEARTS), top, Suit.CLUBS))
|
||||
}
|
||||
|
||||
// -- Legal moves --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `playing an eight offers a choice of suit`() {
|
||||
val hand = listOf(c(Rank.EIGHT, Suit.SPADES))
|
||||
val state = playingState(listOf(hand, emptyList()), top = c(Rank.THREE, Suit.HEARTS))
|
||||
val moves = CrazyEightsRules.legalMoves(state, 0).filterIsInstance<PlayCard>()
|
||||
assertEquals("one move per suit", 4, moves.size)
|
||||
assertEquals(Suit.entries.toSet(), moves.mapNotNull { it.nominatedSuit }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `you must play when you can`() {
|
||||
val hand = listOf(c(Rank.KING, Suit.HEARTS), c(Rank.TWO, Suit.CLUBS))
|
||||
val state = playingState(
|
||||
hands = listOf(hand, emptyList()),
|
||||
top = c(Rank.THREE, Suit.HEARTS),
|
||||
stock = listOf(c(Rank.NINE, Suit.SPADES)),
|
||||
)
|
||||
val moves = CrazyEightsRules.legalMoves(state, 0)
|
||||
assertTrue("no drawing your way out of a playable hand", moves.none { it is DrawCard })
|
||||
assertEquals(1, moves.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing playable you draw`() {
|
||||
val hand = listOf(c(Rank.KING, Suit.CLUBS))
|
||||
val state = playingState(
|
||||
hands = listOf(hand, emptyList()),
|
||||
top = c(Rank.THREE, Suit.HEARTS),
|
||||
stock = listOf(c(Rank.NINE, Suit.SPADES)),
|
||||
)
|
||||
assertEquals(listOf(DrawCard), CrazyEightsRules.legalMoves(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with an empty stock and nothing playable you pass`() {
|
||||
val hand = listOf(c(Rank.KING, Suit.CLUBS))
|
||||
val state = playingState(
|
||||
hands = listOf(hand, emptyList()),
|
||||
top = c(Rank.THREE, Suit.HEARTS),
|
||||
stock = emptyList(),
|
||||
)
|
||||
assertEquals(listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draw one and pass stops after a single card`() {
|
||||
val options = CrazyEightsOptions(playerCount = 2, drawUntilPlayable = false)
|
||||
val hand = listOf(c(Rank.KING, Suit.CLUBS))
|
||||
val state = playingState(
|
||||
hands = listOf(hand, emptyList()),
|
||||
top = c(Rank.THREE, Suit.HEARTS),
|
||||
stock = listOf(c(Rank.NINE, Suit.SPADES), c(Rank.TEN, Suit.SPADES)),
|
||||
options = options,
|
||||
drawnThisTurn = 1,
|
||||
)
|
||||
assertEquals("already drawn, so pass", listOf(PassTurn), CrazyEightsRules.legalMoves(state, 0))
|
||||
}
|
||||
|
||||
// -- Play ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an eight sets the suit in force`() {
|
||||
val hand = listOf(c(Rank.EIGHT, Suit.SPADES), c(Rank.TWO, Suit.CLUBS))
|
||||
var state = playingState(listOf(hand, listOf(c(Rank.FIVE, Suit.CLUBS))),
|
||||
top = c(Rank.THREE, Suit.HEARTS))
|
||||
state = CrazyEightsRules.applyMove(
|
||||
state, 0, PlayCard(c(Rank.EIGHT, Suit.SPADES), Suit.DIAMONDS)
|
||||
)
|
||||
assertEquals(Suit.DIAMONDS, state.suitInForce)
|
||||
assertEquals(c(Rank.EIGHT, Suit.SPADES), state.topCard)
|
||||
assertEquals(1, state.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawing keeps the turn`() {
|
||||
val state = playingState(
|
||||
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
|
||||
top = c(Rank.THREE, Suit.HEARTS),
|
||||
stock = listOf(c(Rank.NINE, Suit.SPADES)),
|
||||
)
|
||||
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
|
||||
assertEquals("still your turn", 0, after.turn)
|
||||
assertEquals(2, after.hands[0].size)
|
||||
assertEquals(1, after.drawnThisTurn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exhausted stock is refilled from the discards`() {
|
||||
val state = playingState(
|
||||
hands = listOf(listOf(c(Rank.KING, Suit.CLUBS)), emptyList()),
|
||||
top = c(Rank.THREE, Suit.HEARTS),
|
||||
stock = emptyList(),
|
||||
).copy(discard = listOf(c(Rank.FOUR, Suit.SPADES), c(Rank.THREE, Suit.HEARTS)))
|
||||
|
||||
val after = CrazyEightsRules.applyMove(state, 0, DrawCard)
|
||||
assertEquals("the card in play stays put", c(Rank.THREE, Suit.HEARTS), after.topCard)
|
||||
assertEquals("and the rest became stock", 2, after.hands[0].size)
|
||||
}
|
||||
|
||||
// -- Scoring ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `penalties charge eights the most`() {
|
||||
assertEquals(50, crazyEightsPenalty(c(Rank.EIGHT, Suit.CLUBS)))
|
||||
assertEquals(10, crazyEightsPenalty(c(Rank.KING, Suit.CLUBS)))
|
||||
assertEquals(10, crazyEightsPenalty(c(Rank.TEN, Suit.CLUBS)))
|
||||
assertEquals(1, crazyEightsPenalty(c(Rank.ACE, Suit.CLUBS)))
|
||||
assertEquals(5, crazyEightsPenalty(c(Rank.FIVE, Suit.CLUBS)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `going out charges everyone else for what they hold`() {
|
||||
val options = CrazyEightsOptions(playerCount = 3, roundsToPlay = 2)
|
||||
var state = playingState(
|
||||
hands = listOf(
|
||||
listOf(c(Rank.THREE, Suit.HEARTS)),
|
||||
listOf(c(Rank.EIGHT, Suit.CLUBS), c(Rank.ACE, Suit.SPADES)),
|
||||
listOf(c(Rank.KING, Suit.SPADES)),
|
||||
),
|
||||
top = c(Rank.FOUR, Suit.HEARTS),
|
||||
options = options,
|
||||
)
|
||||
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
|
||||
|
||||
assertEquals(CrazyEightsPhase.ROUND_OVER, state.phase)
|
||||
assertEquals(0, state.roundWinner)
|
||||
assertEquals("the winner is charged nothing", 0, state.scores[0])
|
||||
assertEquals("an eight and an ace", 51, state.scores[1])
|
||||
assertEquals(10, state.scores[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last round ends the game`() {
|
||||
val options = CrazyEightsOptions(playerCount = 2, roundsToPlay = 1)
|
||||
var state = playingState(
|
||||
hands = listOf(listOf(c(Rank.THREE, Suit.HEARTS)), listOf(c(Rank.KING, Suit.SPADES))),
|
||||
top = c(Rank.FOUR, Suit.HEARTS),
|
||||
options = options,
|
||||
)
|
||||
state = CrazyEightsRules.applyMove(state, 0, PlayCard(c(Rank.THREE, Suit.HEARTS)))
|
||||
assertTrue(CrazyEightsRules.isFinished(state))
|
||||
}
|
||||
|
||||
// -- Dealing ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `two players get seven cards and everyone else five`() {
|
||||
assertEquals(7, CrazyEightsOptions(playerCount = 2).handSize())
|
||||
assertEquals(5, CrazyEightsOptions(playerCount = 4).handSize())
|
||||
assertEquals(9, CrazyEightsOptions(playerCount = 4, startingHand = 9).handSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the opening card is never an eight`() {
|
||||
// An eight on top would leave the suit in force unnamed.
|
||||
repeat(30) { seed ->
|
||||
val state = CrazyEightsRules.initialState(config(seed = seed.toLong()))
|
||||
assertFalse("seed $seed dealt a wild upcard", isWild(state.topCard!!))
|
||||
assertEquals(state.topCard!!.suit, state.suitInForce)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always finishes`() {
|
||||
repeat(20) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = CrazyEightsRules.initialState(
|
||||
config(CrazyEightsOptions(playerCount = 4, roundsToPlay = 2), iteration.toLong())
|
||||
)
|
||||
var guard = 0
|
||||
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
|
||||
val seat = CrazyEightsRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CrazyEightsRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = CrazyEightsRules.legalMoves(state, seat)
|
||||
assertTrue("seat $seat had no legal move", legal.isNotEmpty())
|
||||
state = CrazyEightsRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("game $iteration did not finish", CrazyEightsRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play always finishes and beats random play`() {
|
||||
val ai = CrazyEightsAi()
|
||||
var aiTotal = 0
|
||||
var randomTotal = 0
|
||||
|
||||
repeat(12) { iteration ->
|
||||
val random = Random(3000L + iteration)
|
||||
var state = CrazyEightsRules.initialState(
|
||||
config(CrazyEightsOptions(playerCount = 2, roundsToPlay = 3), 800L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!CrazyEightsRules.isFinished(state) && guard++ < 8000) {
|
||||
val seat = CrazyEightsRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = CrazyEightsRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = CrazyEightsRules.legalMoves(state, seat)
|
||||
val move = if (seat == 0) {
|
||||
ai.chooseMove(state, seat, legal)
|
||||
} else {
|
||||
legal[random.nextInt(legal.size)]
|
||||
}
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = CrazyEightsRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue(CrazyEightsRules.isFinished(state))
|
||||
aiTotal += state.scores[0]
|
||||
randomTotal += state.scores[1]
|
||||
}
|
||||
// Penalty points, so lower is better.
|
||||
assertTrue(
|
||||
"ai took $aiTotal against random's $randomTotal — no better than flailing",
|
||||
aiTotal < randomTotal,
|
||||
)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides other hands and the stock`() {
|
||||
val state = CrazyEightsRules.initialState(config())
|
||||
val view = CrazyEightsRules.viewFor(state, seat = 2)
|
||||
assertEquals(state.hands[2], view.hands[2])
|
||||
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
|
||||
assertTrue(view.stock.isEmpty())
|
||||
assertEquals(state.handCounts, view.handCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = CrazyEightsRules.initialState(config())
|
||||
assertEquals(state, CrazyEightsRules.decodeState(CrazyEightsRules.encodeState(state)))
|
||||
listOf(
|
||||
DrawCard,
|
||||
PassTurn,
|
||||
PlayCard(c(Rank.KING, Suit.HEARTS)),
|
||||
PlayCard(c(Rank.EIGHT, Suit.CLUBS), Suit.DIAMONDS),
|
||||
).forEach {
|
||||
assertEquals(it, CrazyEightsRules.decodeMove(CrazyEightsRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package org.prolibertate.games.game.kaiser
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
class KaiserRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: KaiserOptions = KaiserOptions(), seed: Long = 5L) = TableConfig(
|
||||
gameId = "kaiser",
|
||||
seats = (0 until 4).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// -- The deck -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the deck is thirty-two cards with the two counters in it`() {
|
||||
val deck = Decks.kaiser()
|
||||
assertEquals(32, deck.size)
|
||||
assertEquals("no duplicates", 32, deck.distinct().size)
|
||||
assertTrue("five of hearts", deck.contains(FIVE_OF_HEARTS))
|
||||
assertTrue("three of spades", deck.contains(THREE_OF_SPADES))
|
||||
assertTrue("seven of clubs stays", deck.contains(Card(Rank.SEVEN, Suit.CLUBS)))
|
||||
assertTrue("seven of diamonds stays", deck.contains(Card(Rank.SEVEN, Suit.DIAMONDS)))
|
||||
assertFalse("but not the seven of hearts", deck.contains(Card(Rank.SEVEN, Suit.HEARTS)))
|
||||
assertFalse("nor the seven of spades", deck.contains(Card(Rank.SEVEN, Suit.SPADES)))
|
||||
assertTrue("nothing below the counters", deck.none {
|
||||
it.rank.order < Rank.SEVEN.order && it != FIVE_OF_HEARTS && it != THREE_OF_SPADES
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hand is worth ten points in total`() {
|
||||
// Eight tricks at one each, plus five, less three.
|
||||
val deck = Decks.kaiser()
|
||||
val allTricks = deck.chunked(4)
|
||||
assertEquals(8, allTricks.size)
|
||||
assertEquals(10, allTricks.sumOf { trickValue(it) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the counters move a tricks value`() {
|
||||
val plain = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS))
|
||||
assertEquals(1, trickValue(plain))
|
||||
assertEquals(6, trickValue(plain + FIVE_OF_HEARTS))
|
||||
assertEquals(-2, trickValue(plain + THREE_OF_SPADES))
|
||||
assertEquals(3, trickValue(plain + FIVE_OF_HEARTS + THREE_OF_SPADES))
|
||||
}
|
||||
|
||||
// -- Bidding ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `everyone gets eight cards and bidding opens left of the dealer`() {
|
||||
val state = KaiserRules.initialState(config())
|
||||
assertEquals(KaiserPhase.BIDDING, state.phase)
|
||||
state.hands.forEach { assertEquals(8, it.size) }
|
||||
assertEquals((state.dealer + 1) % 4, state.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bid must beat the one before it`() {
|
||||
var state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 6)))
|
||||
val opener = state.turn
|
||||
state = KaiserRules.applyMove(state, opener, MakeBid(Bid(7, noTrump = false)))
|
||||
|
||||
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
|
||||
assertTrue("nothing at or below seven in suit",
|
||||
moves.none { !it.bid.noTrump && it.bid.points <= 7 })
|
||||
assertTrue("but seven no trump beats seven",
|
||||
moves.any { it.bid.noTrump && it.bid.points == 7 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no trump outranks the same number`() {
|
||||
assertTrue(Bid(7, noTrump = true).beats(Bid(7, noTrump = false)))
|
||||
assertFalse(Bid(7, noTrump = false).beats(Bid(7, noTrump = true)))
|
||||
assertTrue(Bid(8, noTrump = false).beats(Bid(7, noTrump = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bidding floor is respected`() {
|
||||
val state = KaiserRules.initialState(config(KaiserOptions(minimumBid = 8)))
|
||||
val moves = KaiserRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
|
||||
assertTrue(moves.all { it.bid.points >= 8 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dealer is stuck when everyone passes`() {
|
||||
var state = KaiserRules.initialState(config())
|
||||
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
|
||||
assertEquals(state.dealer, state.turn)
|
||||
val moves = KaiserRules.legalMoves(state, state.dealer)
|
||||
assertFalse("the dealer has to bid", moves.contains(PassBid))
|
||||
assertTrue(moves.all { it is MakeBid })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the winning bidder names trump and leads`() {
|
||||
var state = KaiserRules.initialState(config())
|
||||
val bidder = state.turn
|
||||
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = false)))
|
||||
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
|
||||
|
||||
assertEquals("back to the bidder", bidder, state.turn)
|
||||
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
|
||||
assertEquals("a choice of four suits", 4, naming.size)
|
||||
|
||||
state = KaiserRules.applyMove(state, bidder, NameTrump(Suit.SPADES))
|
||||
assertEquals(KaiserPhase.PLAYING, state.phase)
|
||||
assertEquals(Suit.SPADES, state.trump)
|
||||
assertEquals("and leads", bidder, state.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a no trump bid commits to no trump`() {
|
||||
var state = KaiserRules.initialState(config())
|
||||
val bidder = state.turn
|
||||
state = KaiserRules.applyMove(state, bidder, MakeBid(Bid(6, noTrump = true)))
|
||||
repeat(3) { state = KaiserRules.applyMove(state, state.turn, PassBid) }
|
||||
|
||||
val naming = KaiserRules.legalMoves(state, bidder).filterIsInstance<NameTrump>()
|
||||
assertEquals(listOf(NameTrump(null)), naming)
|
||||
state = KaiserRules.applyMove(state, bidder, NameTrump(null))
|
||||
assertEquals(null, state.trump)
|
||||
assertEquals(KaiserPhase.PLAYING, state.phase)
|
||||
}
|
||||
|
||||
// -- Play ---------------------------------------------------------------
|
||||
|
||||
private fun playingState(
|
||||
hands: List<List<Card>>,
|
||||
trump: Suit?,
|
||||
bid: Bid = Bid(6, noTrump = false),
|
||||
bidder: Int = 0,
|
||||
turn: Int = 0,
|
||||
) = KaiserState(
|
||||
options = KaiserOptions(),
|
||||
seed = 1L,
|
||||
handNumber = 0,
|
||||
dealer = 3,
|
||||
phase = KaiserPhase.PLAYING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
turn = turn,
|
||||
passes = 3,
|
||||
highBid = bid,
|
||||
highBidder = bidder,
|
||||
trump = trump,
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
leader = turn,
|
||||
lastTrickWinner = null,
|
||||
handPoints = listOf(0, 0),
|
||||
scores = listOf(0, 0),
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `you must follow suit`() {
|
||||
val hand = listOf(Card(Rank.NINE, Suit.HEARTS), Card(Rank.ACE, Suit.CLUBS))
|
||||
val state = playingState(
|
||||
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||
trump = Suit.SPADES,
|
||||
turn = 1,
|
||||
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
|
||||
assertEquals(listOf(Card(Rank.NINE, Suit.HEARTS)), KaiserRules.playableCards(state, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trump beats the led suit and there are no bowers`() {
|
||||
val trump = Suit.SPADES
|
||||
assertTrue(
|
||||
trickStrength(Card(Rank.EIGHT, trump), trump, Suit.HEARTS) >
|
||||
trickStrength(Card(Rank.ACE, Suit.HEARTS), trump, Suit.HEARTS)
|
||||
)
|
||||
// The jack is just a jack here.
|
||||
assertTrue(
|
||||
trickStrength(Card(Rank.ACE, trump), trump, trump) >
|
||||
trickStrength(Card(Rank.JACK, trump), trump, trump)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `taking the five of hearts is worth five`() {
|
||||
val hands = listOf(
|
||||
listOf(Card(Rank.ACE, Suit.CLUBS)),
|
||||
listOf(FIVE_OF_HEARTS),
|
||||
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
|
||||
listOf(Card(Rank.NINE, Suit.CLUBS)),
|
||||
)
|
||||
var state = playingState(hands, trump = Suit.SPADES)
|
||||
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
|
||||
state = KaiserRules.applyMove(state, 1, PlayCard(FIVE_OF_HEARTS))
|
||||
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
|
||||
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
|
||||
|
||||
assertEquals("seat 0 took it", 0, state.lastTrickWinner)
|
||||
assertEquals("one for the trick plus five", 6, state.handPoints[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `taking the three of spades costs three`() {
|
||||
val hands = listOf(
|
||||
listOf(Card(Rank.ACE, Suit.CLUBS)),
|
||||
listOf(THREE_OF_SPADES),
|
||||
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
|
||||
listOf(Card(Rank.NINE, Suit.CLUBS)),
|
||||
)
|
||||
var state = playingState(hands, trump = Suit.HEARTS)
|
||||
state = KaiserRules.applyMove(state, 0, PlayCard(Card(Rank.ACE, Suit.CLUBS)))
|
||||
state = KaiserRules.applyMove(state, 1, PlayCard(THREE_OF_SPADES))
|
||||
state = KaiserRules.applyMove(state, 2, PlayCard(Card(Rank.EIGHT, Suit.CLUBS)))
|
||||
state = KaiserRules.applyMove(state, 3, PlayCard(Card(Rank.NINE, Suit.CLUBS)))
|
||||
|
||||
assertEquals(0, state.lastTrickWinner)
|
||||
assertEquals("one for the trick less three", -2, state.handPoints[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished trick is held on the table`() {
|
||||
val hands = listOf(
|
||||
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
|
||||
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
|
||||
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
|
||||
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.EIGHT, Suit.HEARTS)),
|
||||
)
|
||||
var state = playingState(hands, trump = Suit.SPADES)
|
||||
repeat(4) {
|
||||
val seat = KaiserRules.currentSeat(state)!!
|
||||
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||
}
|
||||
assertTrue(state.trick.isEmpty())
|
||||
assertEquals("all four held for display", 4, state.completedTrick.size)
|
||||
}
|
||||
|
||||
// -- Scoring ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `making the bid scores what was taken and going down costs the bid`() {
|
||||
val made = scoreOneHand(bid = Bid(6, noTrump = false), bidderTakesAll = true)
|
||||
assertTrue("made contract scores", made.scores[0] > 0)
|
||||
|
||||
val down = scoreOneHand(bid = Bid(10, noTrump = false), bidderTakesAll = false)
|
||||
assertEquals("going down costs the bid", -10, down.scores[0])
|
||||
}
|
||||
|
||||
/** Plays a whole hand where one team either wins every trick or none. */
|
||||
private fun scoreOneHand(bid: Bid, bidderTakesAll: Boolean): KaiserState {
|
||||
val strong = Suit.SPADES
|
||||
// Team 0 holds every spade; team 1 holds none.
|
||||
val hands = listOf(
|
||||
(0 until 8).map { Card(Rank.entries.filter { r -> r.order >= Rank.EIGHT.order }[it % 7], strong) },
|
||||
List(8) { Card(Rank.EIGHT, Suit.HEARTS) },
|
||||
List(8) { Card(Rank.NINE, Suit.HEARTS) },
|
||||
List(8) { Card(Rank.TEN, Suit.HEARTS) },
|
||||
)
|
||||
var state = playingState(hands, trump = strong, bid = bid, bidder = if (bidderTakesAll) 0 else 0)
|
||||
if (!bidderTakesAll) {
|
||||
// Give the spades to the other side instead.
|
||||
state = state.copy(
|
||||
hands = listOf(hands[1], hands[0], hands[2], hands[3]),
|
||||
handCounts = hands.map { it.size },
|
||||
)
|
||||
}
|
||||
var guard = 0
|
||||
while (state.phase == KaiserPhase.PLAYING && guard++ < 100) {
|
||||
val seat = KaiserRules.currentSeat(state)!!
|
||||
val legal = KaiserRules.legalMoves(state, seat)
|
||||
state = KaiserRules.applyMove(state, seat, legal.first())
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `defenders keep what they take`() {
|
||||
var state = KaiserRules.initialState(config())
|
||||
var guard = 0
|
||||
while (state.phase != KaiserPhase.HAND_OVER && !KaiserRules.isFinished(state) && guard++ < 500) {
|
||||
val seat = KaiserRules.currentSeat(state) ?: break
|
||||
val legal = KaiserRules.legalMoves(state, seat)
|
||||
state = KaiserRules.applyMove(state, seat, legal.first())
|
||||
}
|
||||
assertTrue("a hand was scored", state.scores.any { it != 0 } || state.handPoints.any { it != 0 })
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always reaches a winner`() {
|
||||
repeat(15) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = KaiserRules.initialState(config(seed = iteration.toLong()))
|
||||
var guard = 0
|
||||
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
|
||||
val seat = KaiserRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = KaiserRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = KaiserRules.legalMoves(state, seat)
|
||||
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
|
||||
state = KaiserRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("game $iteration did not finish", KaiserRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play always reaches a winner`() {
|
||||
val ai = KaiserAi()
|
||||
repeat(8) { iteration ->
|
||||
var state = KaiserRules.initialState(config(seed = 600L + iteration))
|
||||
var guard = 0
|
||||
while (!KaiserRules.isFinished(state) && guard++ < 8000) {
|
||||
val seat = KaiserRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = KaiserRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = KaiserRules.legalMoves(state, seat)
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = KaiserRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue("ai game did not finish", KaiserRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides every other hand`() {
|
||||
val state = KaiserRules.initialState(config())
|
||||
val view = KaiserRules.viewFor(state, seat = 3)
|
||||
assertEquals(state.hands[3], view.hands[3])
|
||||
assertTrue(view.hands.filterIndexed { i, _ -> i != 3 }.all { it.isEmpty() })
|
||||
assertEquals(listOf(8, 8, 8, 8), view.handCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = KaiserRules.initialState(config())
|
||||
assertEquals(state, KaiserRules.decodeState(KaiserRules.encodeState(state)))
|
||||
listOf(
|
||||
PassBid,
|
||||
MakeBid(Bid(7, noTrump = true)),
|
||||
NameTrump(Suit.HEARTS),
|
||||
NameTrump(null),
|
||||
PlayCard(FIVE_OF_HEARTS),
|
||||
).forEach { assertEquals(it, KaiserRules.decodeMove(KaiserRules.encodeMove(it))) }
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class SequenceRulesTest {
|
||||
@Test
|
||||
fun `every non-jack card maps to exactly two squares`() {
|
||||
val nonJacks = Suit.entries.flatMap { suit ->
|
||||
Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) }
|
||||
Rank.standard.filter { it != Rank.JACK }.map { Card(it, suit) }
|
||||
}
|
||||
nonJacks.forEach { card ->
|
||||
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
package org.prolibertate.games.game.wizard
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
class WizardRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: WizardOptions = WizardOptions(), seed: Long = 11L) = TableConfig(
|
||||
gameId = "wizard",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun wizardOf(suit: Suit) = Card(Rank.WIZARD, suit)
|
||||
private fun jesterOf(suit: Suit) = Card(Rank.JESTER, suit)
|
||||
|
||||
// -- The deck -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the deck is sixty cards with four wizards and four jesters`() {
|
||||
val deck = Decks.wizard()
|
||||
assertEquals(60, deck.size)
|
||||
assertEquals("no duplicates", 60, deck.distinct().size)
|
||||
assertEquals(4, deck.count { isWizardCard(it) })
|
||||
assertEquals(4, deck.count { isJester(it) })
|
||||
assertEquals(52, deck.count { it.rank.standard })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole deck is dealt out over the rounds`() {
|
||||
assertEquals(20, WizardOptions(playerCount = 3).totalRounds())
|
||||
assertEquals(15, WizardOptions(playerCount = 4).totalRounds())
|
||||
assertEquals(12, WizardOptions(playerCount = 5).totalRounds())
|
||||
assertEquals(10, WizardOptions(playerCount = 6).totalRounds())
|
||||
// A short game stops early rather than inventing extra rounds.
|
||||
assertEquals(5, WizardOptions(playerCount = 4, rounds = 5).totalRounds())
|
||||
assertEquals(15, WizardOptions(playerCount = 4, rounds = 99).totalRounds())
|
||||
}
|
||||
|
||||
// -- Dealing and trump --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `round one deals a single card each and turns one for trump`() {
|
||||
val state = WizardRules.initialState(config())
|
||||
assertEquals(WizardPhase.BIDDING, state.phase)
|
||||
state.hands.forEach { assertEquals(1, it.size) }
|
||||
assertEquals(1, state.cardsThisRound)
|
||||
assertEquals("something was turned", true, state.trumpCard != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last round turns nothing and is played without trump`() {
|
||||
// Four players, fifteen rounds: the last one uses every card in the deck.
|
||||
var state = WizardRules.initialState(config())
|
||||
var guard = 0
|
||||
while (state.round < 14 && guard++ < 40) {
|
||||
state = playRoundOut(state)
|
||||
if (state.phase == WizardPhase.ROUND_OVER) state = WizardRules.nextRound(state)
|
||||
}
|
||||
assertEquals(14, state.round)
|
||||
assertEquals(15, state.hands[0].size)
|
||||
assertNull("nothing left to turn", state.trumpCard)
|
||||
assertNull("so there is no trump", state.trump)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turned jester means no trump`() {
|
||||
// Search seeds until one turns a jester rather than asserting on luck.
|
||||
val seed = (1L..400L).firstOrNull { s ->
|
||||
val state = WizardRules.initialState(config(seed = s))
|
||||
state.trumpCard?.let { isJester(it) } == true
|
||||
}
|
||||
requireNotNull(seed) { "no seed in range turned a jester" }
|
||||
val state = WizardRules.initialState(config(seed = seed))
|
||||
assertNull(state.trump)
|
||||
assertTrue("bidding starts left of the dealer", state.turn == (state.dealer + 1) % 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turned wizard hands the dealer the choice of trump`() {
|
||||
val seed = (1L..400L).firstOrNull { s ->
|
||||
val state = WizardRules.initialState(config(seed = s))
|
||||
state.trumpCard?.let { isWizardCard(it) } == true
|
||||
}
|
||||
requireNotNull(seed) { "no seed in range turned a wizard" }
|
||||
var state = WizardRules.initialState(config(seed = seed))
|
||||
|
||||
assertNull("trump is not set yet", state.trump)
|
||||
assertEquals("the dealer is on turn", state.dealer, state.turn)
|
||||
val choices = WizardRules.legalMoves(state, state.dealer)
|
||||
assertEquals(Suit.entries.map { ChooseTrump(it) }, choices)
|
||||
|
||||
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.HEARTS))
|
||||
assertEquals(Suit.HEARTS, state.trump)
|
||||
assertEquals("then bidding opens on the left", (state.dealer + 1) % 4, state.turn)
|
||||
assertTrue(WizardRules.legalMoves(state, state.turn).all { it is MakeBid })
|
||||
}
|
||||
|
||||
// -- Bidding ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `you may bid anything from zero up to the tricks available`() {
|
||||
val state = WizardRules.initialState(config())
|
||||
val bids = WizardRules.legalMoves(state, state.turn).filterIsInstance<MakeBid>()
|
||||
assertEquals(listOf(0, 1), bids.map { it.tricks })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screw the dealer forbids the bid that would balance the book`() {
|
||||
var state = WizardRules.initialState(
|
||||
config(WizardOptions(screwTheDealer = true, rounds = 4), seed = 3L)
|
||||
)
|
||||
// Get to a round with room to bid, then walk round to the dealer.
|
||||
while (state.cardsThisRound < 3) {
|
||||
state = playRoundOut(state)
|
||||
state = WizardRules.nextRound(state)
|
||||
}
|
||||
if (state.trumpCard?.let { isWizardCard(it) } == true) {
|
||||
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
|
||||
}
|
||||
val available = state.cardsThisRound
|
||||
var bidSoFar = 0
|
||||
while (state.turn != state.dealer) {
|
||||
val seat = state.turn
|
||||
state = WizardRules.applyMove(state, seat, MakeBid(1))
|
||||
bidSoFar += 1
|
||||
}
|
||||
val dealerBids = WizardRules.legalMoves(state, state.dealer)
|
||||
.filterIsInstance<MakeBid>().map { it.tricks }
|
||||
assertFalse("the balancing bid is off the table", dealerBids.contains(available - bidSoFar))
|
||||
assertEquals(available, dealerBids.size) // one fewer than the 0..available range
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play starts once everyone has bid`() {
|
||||
var state = WizardRules.initialState(config())
|
||||
if (state.trumpCard?.let { isWizardCard(it) } == true) {
|
||||
state = WizardRules.applyMove(state, state.dealer, ChooseTrump(Suit.SPADES))
|
||||
}
|
||||
repeat(4) { state = WizardRules.applyMove(state, state.turn, MakeBid(0)) }
|
||||
assertEquals(WizardPhase.PLAYING, state.phase)
|
||||
assertEquals("the player left of the dealer leads", (state.dealer + 1) % 4, state.turn)
|
||||
}
|
||||
|
||||
// -- Trick ranking ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a wizard beats everything including trump`() {
|
||||
val trump = Suit.SPADES
|
||||
assertTrue(
|
||||
trickStrength(wizardOf(Suit.CLUBS), 1, trump, Suit.HEARTS) >
|
||||
trickStrength(Card(Rank.ACE, trump), 0, trump, Suit.HEARTS)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first wizard down takes the trick`() {
|
||||
assertTrue(
|
||||
trickStrength(wizardOf(Suit.CLUBS), 0, Suit.SPADES, null) >
|
||||
trickStrength(wizardOf(Suit.HEARTS), 3, Suit.SPADES, null)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jester loses to everything, even an off-suit discard`() {
|
||||
val trump = Suit.SPADES
|
||||
val jester = trickStrength(jesterOf(Suit.CLUBS), 1, trump, Suit.HEARTS)
|
||||
val offSuit = trickStrength(Card(Rank.TWO, Suit.DIAMONDS), 2, trump, Suit.HEARTS)
|
||||
assertTrue(offSuit > jester)
|
||||
assertTrue(trickStrength(Card(Rank.TWO, Suit.HEARTS), 2, trump, Suit.HEARTS) > offSuit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trick of nothing but jesters goes to the first one played`() {
|
||||
val hands = List(4) { seat -> listOf(jesterOf(Suit.entries[seat])) }
|
||||
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(0, 0, 0, 0))
|
||||
repeat(4) {
|
||||
val seat = WizardRules.currentSeat(state)!!
|
||||
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||
}
|
||||
assertEquals(0, state.lastTrickWinner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a led jester leaves the suit to the next ordinary card`() {
|
||||
val trick = listOf(
|
||||
PlayedCard(0, jesterOf(Suit.CLUBS)),
|
||||
PlayedCard(1, Card(Rank.NINE, Suit.HEARTS)),
|
||||
)
|
||||
assertEquals(Suit.HEARTS, ledSuitOf(trick))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wizard before any suit is established means anything may be played`() {
|
||||
assertNull(ledSuitOf(listOf(PlayedCard(0, wizardOf(Suit.CLUBS)))))
|
||||
assertNull(
|
||||
ledSuitOf(
|
||||
listOf(
|
||||
PlayedCard(0, jesterOf(Suit.CLUBS)),
|
||||
PlayedCard(1, wizardOf(Suit.HEARTS)),
|
||||
PlayedCard(2, Card(Rank.NINE, Suit.SPADES)),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// -- Following suit -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `you must follow suit but may always throw a wizard or a jester`() {
|
||||
val hand = listOf(
|
||||
Card(Rank.NINE, Suit.HEARTS),
|
||||
Card(Rank.ACE, Suit.CLUBS),
|
||||
wizardOf(Suit.SPADES),
|
||||
jesterOf(Suit.DIAMONDS),
|
||||
)
|
||||
val state = playingState(
|
||||
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||
trump = Suit.SPADES,
|
||||
bids = listOf(0, 1, 0, 0),
|
||||
turn = 1,
|
||||
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
|
||||
|
||||
val playable = WizardRules.playableCards(state, 1)
|
||||
assertTrue(playable.contains(Card(Rank.NINE, Suit.HEARTS)))
|
||||
assertTrue(playable.contains(wizardOf(Suit.SPADES)))
|
||||
assertTrue(playable.contains(jesterOf(Suit.DIAMONDS)))
|
||||
assertFalse("the ace of clubs is not a heart", playable.contains(Card(Rank.ACE, Suit.CLUBS)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with none of the led suit you may play anything`() {
|
||||
val hand = listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.TWO, Suit.SPADES))
|
||||
val state = playingState(
|
||||
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||
trump = Suit.SPADES,
|
||||
bids = listOf(0, 1, 0, 0),
|
||||
turn = 1,
|
||||
).copy(trick = listOf(PlayedCard(0, Card(Rank.KING, Suit.HEARTS))))
|
||||
assertEquals(hand, WizardRules.playableCards(state, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished trick is held on the table`() {
|
||||
val hands = listOf(
|
||||
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
|
||||
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
|
||||
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.QUEEN, Suit.CLUBS)),
|
||||
listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.TWO, Suit.HEARTS)),
|
||||
)
|
||||
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
|
||||
repeat(4) {
|
||||
val seat = WizardRules.currentSeat(state)!!
|
||||
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||
}
|
||||
assertTrue(state.trick.isEmpty())
|
||||
assertEquals("all four held for display", 4, state.completedTrick.size)
|
||||
assertEquals("seat 0 took it with the ace", 0, state.lastTrickWinner)
|
||||
}
|
||||
|
||||
// -- Scoring ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an exact bid pays twenty plus ten a trick`() {
|
||||
assertEquals(20, scoreRound(bid = 0, won = 0))
|
||||
assertEquals(30, scoreRound(bid = 1, won = 1))
|
||||
assertEquals(50, scoreRound(bid = 3, won = 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing costs ten for every trick out either way`() {
|
||||
assertEquals(-10, scoreRound(bid = 0, won = 1))
|
||||
assertEquals(-30, scoreRound(bid = 3, won = 0))
|
||||
assertEquals(-20, scoreRound(bid = 1, won = 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the round is scored when the last trick is taken`() {
|
||||
val hands = listOf(
|
||||
listOf(Card(Rank.ACE, Suit.CLUBS)),
|
||||
listOf(Card(Rank.EIGHT, Suit.CLUBS)),
|
||||
listOf(Card(Rank.NINE, Suit.CLUBS)),
|
||||
listOf(Card(Rank.TEN, Suit.CLUBS)),
|
||||
)
|
||||
var state = playingState(hands, trump = Suit.SPADES, bids = listOf(1, 0, 0, 1))
|
||||
repeat(4) {
|
||||
val seat = WizardRules.currentSeat(state)!!
|
||||
state = WizardRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
|
||||
}
|
||||
assertEquals(WizardPhase.ROUND_OVER, state.phase)
|
||||
assertEquals("bid one and took it", 30, state.roundScores[0])
|
||||
assertEquals("bid none and took none", 20, state.roundScores[1])
|
||||
assertEquals("bid one and took none", -10, state.roundScores[3])
|
||||
assertEquals(state.roundScores, state.scores)
|
||||
assertNull("no seat is on turn between rounds", WizardRules.currentSeat(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the next round deals one more card and moves the deal on`() {
|
||||
var state = WizardRules.initialState(config())
|
||||
val dealer = state.dealer
|
||||
state = playRoundOut(state)
|
||||
assertEquals(WizardPhase.ROUND_OVER, state.phase)
|
||||
|
||||
val next = WizardRules.nextRound(state)
|
||||
assertEquals(1, next.round)
|
||||
assertEquals(2, next.cardsThisRound)
|
||||
next.hands.forEach { assertEquals(2, it.size) }
|
||||
assertEquals((dealer + 1) % 4, next.dealer)
|
||||
assertEquals("scores carry over", state.scores, next.scores)
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always reaches the end of the game`() {
|
||||
for (players in 3..6) {
|
||||
repeat(3) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
val options = WizardOptions(playerCount = players)
|
||||
var state = WizardRules.initialState(
|
||||
config(options, seed = 100L * players + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
|
||||
val seat = WizardRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = WizardRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = WizardRules.legalMoves(state, seat)
|
||||
assertTrue("seat $seat had no move in ${state.phase}", legal.isNotEmpty())
|
||||
state = WizardRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("$players-handed game $iteration did not finish",
|
||||
WizardRules.isFinished(state))
|
||||
assertEquals(options.totalRounds() - 1, state.round)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play always reaches the end of the game`() {
|
||||
val ai = WizardAi()
|
||||
repeat(4) { iteration ->
|
||||
var state = WizardRules.initialState(config(seed = 700L + iteration))
|
||||
var guard = 0
|
||||
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
|
||||
val seat = WizardRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = WizardRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = WizardRules.legalMoves(state, seat)
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = WizardRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue("ai game did not finish", WizardRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ai hits its bid more often than chance would`() {
|
||||
val ai = WizardAi()
|
||||
var exact = 0
|
||||
var rounds = 0
|
||||
repeat(6) { iteration ->
|
||||
var state = WizardRules.initialState(config(seed = 900L + iteration))
|
||||
var guard = 0
|
||||
while (!WizardRules.isFinished(state) && guard++ < 20_000) {
|
||||
val seat = WizardRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
exact += state.roundScores.count { it > 0 }
|
||||
rounds += state.roundScores.size
|
||||
state = WizardRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = WizardRules.legalMoves(state, seat)
|
||||
state = WizardRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
}
|
||||
// Blind guessing over a fifteen-round game lands well under a fifth of
|
||||
// the time; anything paying attention should clear a quarter.
|
||||
assertTrue("only $exact of $rounds bids were exact", exact * 4 >= rounds)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides every other hand`() {
|
||||
val state = WizardRules.initialState(config(WizardOptions(playerCount = 4)))
|
||||
val view = WizardRules.viewFor(state, seat = 2)
|
||||
assertEquals(state.hands[2], view.hands[2])
|
||||
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
|
||||
assertEquals(listOf(1, 1, 1, 1), view.handCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = WizardRules.initialState(config())
|
||||
assertEquals(state, WizardRules.decodeState(WizardRules.encodeState(state)))
|
||||
listOf(
|
||||
MakeBid(0),
|
||||
MakeBid(3),
|
||||
ChooseTrump(Suit.DIAMONDS),
|
||||
PlayCard(wizardOf(Suit.SPADES)),
|
||||
PlayCard(jesterOf(Suit.HEARTS)),
|
||||
).forEach { assertEquals(it, WizardRules.decodeMove(WizardRules.encodeMove(it))) }
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------------
|
||||
|
||||
private fun playingState(
|
||||
hands: List<List<Card>>,
|
||||
trump: Suit?,
|
||||
bids: List<Int?>,
|
||||
turn: Int = 0,
|
||||
) = WizardState(
|
||||
options = WizardOptions(playerCount = hands.size),
|
||||
seed = 1L,
|
||||
round = hands.first().size - 1,
|
||||
dealer = hands.size - 1,
|
||||
phase = WizardPhase.PLAYING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
trumpCard = null,
|
||||
trump = trump,
|
||||
bids = bids,
|
||||
tricksWon = List(hands.size) { 0 },
|
||||
turn = turn,
|
||||
trick = emptyList(),
|
||||
completedTrick = emptyList(),
|
||||
leader = turn,
|
||||
lastTrickWinner = null,
|
||||
scores = List(hands.size) { 0 },
|
||||
roundScores = List(hands.size) { 0 },
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
/** Bids zero all round and plays the first legal card until the round ends. */
|
||||
private fun playRoundOut(start: WizardState): WizardState {
|
||||
var state = start
|
||||
var guard = 0
|
||||
while (state.phase == WizardPhase.BIDDING || state.phase == WizardPhase.PLAYING) {
|
||||
if (guard++ > 5000) break
|
||||
val seat = WizardRules.currentSeat(state)!!
|
||||
val legal = WizardRules.legalMoves(state, seat)
|
||||
state = WizardRules.applyMove(state, seat, legal.first())
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user