diff --git a/README.md b/README.md index 43b356b..5cf4362 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth. ## What works today -The app shell is complete and four games are playable end to end: +The app shell is complete and seven games are playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Sequence, President, Golf | -| **Listed, not yet implemented** | Kaiser, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers | +| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence | +| **Listed, not yet implemented** | Mastermind, Backgammon, Chess, Checkers | Unimplemented games appear in the menu marked *Coming soon* **on dev builds only** — a production release lists just the games that can actually be played, @@ -148,8 +148,11 @@ game: - [Euchre](docs/RULES-euchre.md) - [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** +- [Kaiser](docs/RULES-kaiser.md) - [President](docs/RULES-president.md) - [Golf](docs/RULES-golf.md) +- [Wizard](docs/RULES-wizard.md) +- [Crazy 8s](docs/RULES-crazy8s.md) ## Layout @@ -158,9 +161,12 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested cards/ deck, suits, ranks engine/ GameRules / GameAi contracts, seats, table config euchre/ Euchre model, rules, AI + kaiser/ Kaiser model, bidding, rules, AI sequence/ Sequence model, board, rules, AI president/ President model, rules, AI golf/ Golf model, scoring, rules, AI + wizard/ Wizard model, rules, AI + crazyeights/ Crazy 8s model, rules, AI net/ wire protocol, LAN and Bluetooth transports, lobby, match driver settings/ DataStore-backed preferences update/ GitHub Releases OTA updater diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index c65f956..f942acf 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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, diff --git a/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt b/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt index 652cc93..0617afb 100644 --- a/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt +++ b/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt @@ -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 get() = entries.filter { it.standard } + } } @Serializable @@ -61,7 +79,7 @@ object Decks { /** Standard 52-card deck. */ fun standard52(): List = - 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 = 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 = 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 { + 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), + ) + } } /** diff --git a/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsAi.kt b/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsAi.kt new file mode 100644 index 0000000..caba26b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsAi.kt @@ -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 { + + 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 { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val plays = legal.filterIsInstance() + 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() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsModel.kt b/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsModel.kt new file mode 100644 index 0000000..d32ed79 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsModel.kt @@ -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>, + val handCounts: List, + val stock: List, + val discard: List, + /** + * 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, + val roundWinner: Int?, + val phase: CrazyEightsPhase, + val log: List, +) { + 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 +} diff --git a/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsRules.kt b/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsRules.kt new file mode 100644 index 0000000..cddc66f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsRules.kt @@ -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 { + + 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(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, + log: List, + ): CrazyEightsState { + val random = Random(seed + roundNumber * 7919L) + var deck = Decks.standard52().shuffledWith(random) + + val hands = mutableListOf>() + 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 { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt new file mode 100644 index 0000000..f80b2b8 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt @@ -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 { + + 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 { + 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, 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 { + val naming = legal.filterIsInstance() + 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().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 { + val cards = legal.filterIsInstance().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 }) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt new file mode 100644 index 0000000..127e5d2 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt @@ -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): 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>, + val handCounts: List, + val turn: Int, + val passes: Int, + val highBid: Bid?, + val highBidder: Int?, + val trump: Suit?, + val trick: List, + /** Held on the table so a finished trick can be read before it is swept. */ + val completedTrick: List = emptyList(), + val leader: Int, + val lastTrickWinner: Int?, + /** Points taken this hand, per team. */ + val handPoints: List, + val scores: List, + val log: List, +) + +@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 +} diff --git a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt new file mode 100644 index 0000000..de33e81 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt @@ -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 { + + 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(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, + log: List, + ): 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 { + 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 { + // 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() + 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 { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt b/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt new file mode 100644 index 0000000..e6e65e6 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt @@ -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 { + + 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 { + 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 { + val naming = legal.filterIsInstance() + if (naming.isNotEmpty()) { + val hand = state.hands[seat] + val best = naming.maxByOrNull { suitStrength(hand, it.suit) } + return best ?: naming.first() + } + + val bids = legal.filterIsInstance() + 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, 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, 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 { + val cards = legal.filterIsInstance().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, 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() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/wizard/WizardModel.kt b/app/src/main/java/org/prolibertate/games/game/wizard/WizardModel.kt new file mode 100644 index 0000000..9840a60 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/wizard/WizardModel.kt @@ -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>, + val handCounts: List, + /** 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, + val tricksWon: List, + val turn: Int, + val trick: List, + /** Held on the table so a finished trick can be read before it is swept. */ + val completedTrick: List = emptyList(), + val leader: Int, + val lastTrickWinner: Int?, + val scores: List, + val roundScores: List, + val log: List, +) { + 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): 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) diff --git a/app/src/main/java/org/prolibertate/games/game/wizard/WizardRules.kt b/app/src/main/java/org/prolibertate/games/game/wizard/WizardRules.kt new file mode 100644 index 0000000..ce3cec3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/wizard/WizardRules.kt @@ -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 { + + override val gameId: String = GameCatalog.WIZARD + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): WizardState { + val options = json.decodeFromString(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, + log: List, + ): WizardState { + val random = Random(seed + round * 7919L) + var deck = Decks.wizard().shuffledWith(random) + val cards = round + 1 + + val hands = mutableListOf>() + 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 { + 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 { + 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 { + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 6af6d44..0443203 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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(optionsJson).playerCount + + GameCatalog.PRESIDENT -> + setupJson.decodeFromString(optionsJson).playerCount + + GameCatalog.GOLF -> + setupJson.decodeFromString(optionsJson).playerCount + + GameCatalog.CRAZY_EIGHTS -> + setupJson.decodeFromString(optionsJson).playerCount + + GameCatalog.WIZARD -> + setupJson.decodeFromString(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 ChipRow( diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt index 1d0eb6c..6c8e016 100644 --- a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -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, diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index e2097fe..c8771c0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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( + 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( + 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( + 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.") } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt new file mode 100644 index 0000000..98f4553 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt @@ -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, + 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(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, + 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() + .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().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, + cardWidth: Dp, + pendingWild: Card?, + onTap: (Card) -> Unit, +) { + val hand = state.hands.getOrNull(localSeat).orEmpty() + val playable = legal.filterIsInstance().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") + } + }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt new file mode 100644 index 0000000..c907d9b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt @@ -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, + 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, + 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() + 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() + 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, + cardWidth: Dp, + onPlay: (KaiserMove) -> Unit, +) { + val hand = state.hands.getOrNull(seat).orEmpty() + val playable = legal.filterIsInstance().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)) }, + ) + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt new file mode 100644 index 0000000..56fcf7e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt @@ -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, + 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, + 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() + 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() + 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, + cardWidth: Dp, + onPlay: (WizardMove) -> Unit, +) { + val hand = state.hands.getOrNull(seat).orEmpty() + val playable = legal.filterIsInstance().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") + } + }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index cf09989..b4c2ae7 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -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(), ) diff --git a/app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsRulesTest.kt new file mode 100644 index 0000000..cb461a7 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsRulesTest.kt @@ -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>, + top: Card, + suitInForce: Suit = top.suit, + stock: List = 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() + 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))) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt new file mode 100644 index 0000000..37ab58a --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt @@ -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() + 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() + 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() + 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() + 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>, + 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))) } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt index 844e5cb..cdbeac8 100644 --- a/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt @@ -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) diff --git a/app/src/test/java/org/prolibertate/games/game/wizard/WizardRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/wizard/WizardRulesTest.kt new file mode 100644 index 0000000..8c49013 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/wizard/WizardRulesTest.kt @@ -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() + 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().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>, + trump: Suit?, + bids: List, + 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 + } +} diff --git a/docs/RULES-crazy8s.md b/docs/RULES-crazy8s.md new file mode 100644 index 0000000..6c3e081 --- /dev/null +++ b/docs/RULES-crazy8s.md @@ -0,0 +1,59 @@ +# Crazy 8s — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is in the +game setup screen. + +## Table + +- 2 to 6 players — **toggle**. Everyone plays for themselves. +- One 52-card deck. +- Seven cards each heads-up, five otherwise — **toggle** to fix it at 5, 7 or 9 + instead. +- The rest becomes the stock; one card is turned up to start the discard pile. + If that card is an eight, the suit it shows is the one in force. + +## Play + +On your turn, play one card that matches either + +- the **suit in force**, or +- the **rank** of the card on top of the pile. + +**Eights are wild.** An eight may be played on anything, and the player names +the suit that is then in force. This is why the suit in force is stated in words +on the table rather than left to be read off the pile — after an eight, the top +card tells you nothing. + +If you cannot play: + +- **Draw until you can — *toggle*, on by default.** Keep drawing until a + playable card turns up, or until there is nothing left to draw. +- With the toggle off, draw a single card; if it still does not play, the turn + passes. +- When the stock is empty it is rebuilt from the discards, leaving the top card + in place. If there is genuinely nothing to draw and nothing to play, you pass. + +If every player passes in turn, the round is blocked and ends with nobody out. + +## Scoring + +The round ends when a player sheds their last card. Everyone else scores penalty +points for what they were caught holding: + +| Card | Penalty | +| --- | --- | +| Eight | 50 | +| Ten, jack, queen, king | 10 | +| Ace | 1 | +| Everything else | Face value | + +A blocked round is scored the same way — everyone still holding cards pays for +them. + +Lowest total after the last round wins — **toggle** for 1, 3 or 5 rounds. + +## What the computer opponent does + +It plays the most expensive card it can get rid of, saves its eights until the +endgame or until it has nothing else, and when it does play one it names the +suit it holds most of. diff --git a/docs/RULES-kaiser.md b/docs/RULES-kaiser.md new file mode 100644 index 0000000..470301a --- /dev/null +++ b/docs/RULES-kaiser.md @@ -0,0 +1,78 @@ +# Kaiser — implemented rules + +Kaiser is the prairie game, played hard in Saskatchewan and not much anywhere +else. This is the ruleset the engine enforces. Anything marked **toggle** is in +the game setup screen. + +## Table + +Four players in two partnerships, sitting opposite. Seats 0 and 2 are one team, +seats 1 and 3 the other. + +## The deck — 32 cards + +Eight through ace in every suit, plus: + +- the **seven of clubs** and the **seven of diamonds**, which are ordinary cards + making up the count, and +- the **five of hearts** and the **three of spades**, which replace the other + two sevens and are what the whole game is played for. + +Eight cards each, and the deck is exhausted. + +## What a hand is worth + +Ten points, always: + +| Source | Points | +| --- | --- | +| Each of the eight tricks | 1 | +| Taking the five of hearts | +5 | +| Taking the three of spades | −3 | + +So a trick containing the five of hearts is worth six, and one containing the +three of spades is worth minus two. Both in the same trick is worth three. + +## Bidding + +Bidding opens to the dealer's left and goes round. Each bid is a number of +points the bidder's side undertakes to take. + +- The floor is **5, 6, 7 or 8** — **toggle**, six by default. +- The ceiling is ten, because ten is the most a hand can be worth. Bidding + above what exists is only a way to guarantee going down, so it is not offered. +- **No trump** — **toggle**, on by default. A no-trump bid outranks the same + number bid with a suit; it pays double and costs double. +- Each bid must beat the one before it. +- If the first three players pass, the dealer must bid — there is no throwing + the hand in from the dealer's seat. If all four pass (only possible when the + dealer's forced bid is somehow not reached), the hand is redealt. + +The winning bidder then names trump, or is committed to no trump if that is what +they bid. + +## Play + +The bidder leads. Follow suit if you can; otherwise play anything. + +There are no bowers — Kaiser ranks straight down from the ace, so the jack is +just a jack. Trump beats every other suit; otherwise only the led suit can win. + +## Scoring + +- **Bidding side made it** (took at least the bid): they score every point they + took, doubled if the contract was no trump. +- **Bidding side went down**: they lose the amount of the bid, doubled if it was + no trump. +- **Defenders always keep what they took**, made or not. + +First side to the target wins — **toggle** for 32, 52 or 62 points. A side that +falls to the matching negative score loses outright; without that floor a table +that keeps bidding and keeps going down never reaches a result at all. + +## What the computer opponent does + +It rates each suit by trump length and top cards, adds for holding the five of +hearts and subtracts for holding the three of spades, and bids the floor rather +than reaching. In play it chases the five of hearts, ducks when the three of +spades is on the table, and lets its partner have a trick it is already winning. diff --git a/docs/RULES-wizard.md b/docs/RULES-wizard.md new file mode 100644 index 0000000..df9a0b1 --- /dev/null +++ b/docs/RULES-wizard.md @@ -0,0 +1,81 @@ +# Wizard — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is in the +game setup screen. + +## Table + +- 3 to 6 players — **toggle**. Everyone plays for themselves. + +## The deck — 60 cards + +An ordinary 52-card pack plus **four wizards** and **four jesters**. + +## Rounds + +Round 1 deals one card each, round 2 deals two, and so on until the pack runs +out. That is 20 rounds with three players, 15 with four, 12 with five and 10 +with six. **Toggle** to stop after 5 or 10 rounds instead of playing the lot. + +After the deal, the next card off the pack is turned for trump: + +- an ordinary card — its suit is trump; +- a **jester** — the round is played with no trump; +- a **wizard** — the dealer names trump before anyone bids. + +In the last round the whole pack has been dealt, so there is no card to turn and +no trump. + +## Bidding + +Starting to the dealer's left, each player says exactly how many tricks they +will take, from none up to the number of cards in hand. Every bid is heard, so +the later you bid the more you know. + +**Screw the dealer — *toggle*, off by default.** The dealer may not name the +number that would make the bids add up to the tricks available, so at least one +player at the table is always going to be wrong. + +## Play + +The player to the dealer's left leads. Follow the led suit if you hold it — +except that a **wizard or a jester may always be played**, whatever you hold. +Playing one is how you get out of following. + +**What suit is led:** + +- an ordinary card leads its suit; +- a **jester** leads nothing — the suit is set by the next ordinary card; +- a **wizard** reaching the front means no suit is led at all, and everyone may + play whatever they like. + +**Who takes the trick:** + +1. The **first wizard** played takes it, whatever else is down. +2. Otherwise the highest trump. +3. Otherwise the highest card of the led suit. +4. A **jester** never wins — except a trick of nothing but jesters, which goes + to the first one played. + +## Scoring + +| Result | Score | +| --- | --- | +| Bid made exactly | 20, plus 10 for each trick taken | +| Bid missed | −10 for every trick over or under | + +So bidding none and taking none is worth 20; bidding three and taking three is +worth 50; bidding three and taking one costs 20. + +Highest total after the last round wins. + +## What the computer opponent does + +It counts its expected tricks — a wizard is one, a jester is none, and +everything else is a fraction depending on how high it is and whether it is +trump — and bids the nearest number still on offer. In play it chases tricks +while it is short of its bid, taking them as cheaply as it can, and ducks once +it has them, leading a jester when it has one to spare. + +Over long runs of self-play it lands its bid exactly around half the time, +against about one in six for random legal play.